check / check (push) Waiting to run
`internal/handlers/source_management.go` had grown to about 2,370 lines holding the webhook, event log, entrypoint and target handlers, so unrelated units had to wait on each other to touch it. It is split, as pure code movement, into files named for what they hold: `webhook_list.go`, `webhook_create.go`, `webhook_detail.go`, `webhook_edit.go`, `webhook_delete.go`, `event_log.go`, `entrypoint.go`, `target_create.go`, `target_delete.go`, `target_toggle.go` and `shared.go`. No function body, signature, comment or behaviour changed. The README's file tree and log-line caveat, and one middleware comment, name the new files. Model: opus-5-5
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi"
|
|
"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.renameMu.Lock()
|
|
defer h.renameMu.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, err := h.newTarget(r.Context(), webhook.ID, in)
|
|
if err != nil {
|
|
h.serverError(w, r, "failed to encode target config", err)
|
|
|
|
return
|
|
}
|
|
|
|
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. An error is the server's fault, not a refusal: the accepted
|
|
// configuration could not be encoded. 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, error) {
|
|
target := &database.Target{
|
|
WebhookID: webhookID,
|
|
Type: in.Type,
|
|
Active: true,
|
|
}
|
|
|
|
errMsg, err := h.setTargetFromForm(ctx, target, in)
|
|
if err != nil || errMsg != "" {
|
|
return nil, errMsg, err
|
|
}
|
|
|
|
return target, "", nil
|
|
}
|
|
|
|
// setTargetFromForm validates a target form against the target's type
|
|
// and, when it accepts it, sets the target's name, configuration and
|
|
// retry count from it. It returns the message the form shows for
|
|
// anything it refuses, an unknown type among them, and then leaves the
|
|
// target unchanged; an error is the server's fault, as for newTarget.
|
|
// 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, error) {
|
|
if in.Name == "" {
|
|
return "Name is required", nil
|
|
}
|
|
|
|
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
|
|
if err != nil || errMsg != "" {
|
|
return errMsg, err
|
|
}
|
|
|
|
// An empty max_retries keeps the target's count: the
|
|
// fire-and-forget default of 0 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.
|
|
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
|
|
if err != nil {
|
|
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
|
|
}
|
|
|
|
target.Name = in.Name
|
|
target.Config = configJSON
|
|
target.MaxRetries = maxRetries
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// 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
|
|
// MaxRetries is an HTTP or Slack target's max_retries.
|
|
MaxRetries 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"),
|
|
MaxRetries: r.PostFormValue("max_retries"),
|
|
Expiry: r.PostFormValue("expiry"),
|
|
Rotation: r.PostFormValue("rotation"),
|
|
}
|
|
}
|
|
|
|
// buildTargetConfig builds the JSON config string for a target from
|
|
// the submitted form values, or returns the message the form shows
|
|
// for a value it refuses. An error is the server's fault, not a
|
|
// refusal: the accepted configuration could not be encoded. Which
|
|
// fields of in apply depends on the target type; a type without a URL
|
|
// ignores any URL submitted.
|
|
func (h *Handlers) buildTargetConfig(
|
|
ctx context.Context,
|
|
targetType database.TargetType,
|
|
in targetFormInput,
|
|
) (string, string, error) {
|
|
switch targetType {
|
|
case database.TargetTypeHTTP:
|
|
return h.buildHTTPTargetConfig(ctx, in)
|
|
case database.TargetTypeSlack:
|
|
return h.buildSlackTargetConfig(ctx, in.URL)
|
|
case database.TargetTypeDatabase:
|
|
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
|
|
case database.TargetTypeLog:
|
|
return "", "", nil
|
|
default:
|
|
return "", "Invalid target type", nil
|
|
}
|
|
}
|
|
|
|
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
|
|
// SSRF-validated destination plus the optional headers and timeout
|
|
// the delivery path honours.
|
|
func (h *Handlers) buildHTTPTargetConfig(
|
|
ctx context.Context,
|
|
in targetFormInput,
|
|
) (string, string, error) {
|
|
errMsg := h.validateTargetURL(
|
|
ctx, in.URL, "URL is required for HTTP targets",
|
|
)
|
|
if errMsg != "" {
|
|
return "", errMsg, nil
|
|
}
|
|
|
|
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
|
if err != nil {
|
|
return "", fmt.Sprintf("Invalid headers: %v", err), nil
|
|
}
|
|
|
|
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
|
if err != nil {
|
|
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
|
|
}
|
|
|
|
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
|
|
URL: in.URL,
|
|
Headers: headers,
|
|
Timeout: timeout,
|
|
})
|
|
|
|
return configJSON, "", err
|
|
}
|
|
|
|
// buildSlackTargetConfig builds config JSON for a Slack target,
|
|
// whose whole configuration is one SSRF-validated webhook URL.
|
|
func (h *Handlers) buildSlackTargetConfig(
|
|
ctx context.Context,
|
|
targetURL string,
|
|
) (string, string, error) {
|
|
errMsg := h.validateTargetURL(
|
|
ctx, targetURL,
|
|
"Webhook URL is required for Slack targets",
|
|
)
|
|
if errMsg != "" {
|
|
return "", errMsg, nil
|
|
}
|
|
|
|
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
|
|
WebhookURL: targetURL,
|
|
})
|
|
|
|
return configJSON, "", err
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
|
|
// marshalTargetConfig serialises a target configuration for storage.
|
|
func marshalTargetConfig(cfg any) (string, error) {
|
|
configBytes, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(configBytes), nil
|
|
}
|
|
|
|
// buildDatabaseTargetConfig builds config JSON for a database
|
|
// (archive) target. The optional expiry and rotation are validated
|
|
// here, at creation time, so a bad value is refused instead of
|
|
// failing every subsequent delivery. Each is stored only when set,
|
|
// and with neither the config is empty (the keep-forever, one-file
|
|
// default).
|
|
func buildDatabaseTargetConfig(
|
|
expiry, rotation string,
|
|
) (string, string, error) {
|
|
expiry = strings.TrimSpace(expiry)
|
|
|
|
err := delivery.ValidateArchiveExpiry(expiry)
|
|
if err != nil {
|
|
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
|
|
}
|
|
|
|
err = delivery.ValidateArchiveRotation(rotation)
|
|
if err != nil {
|
|
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
|
|
}
|
|
|
|
cfg := map[string]any{}
|
|
|
|
if expiry != "" {
|
|
cfg["expiry"] = expiry
|
|
}
|
|
|
|
if rotation != "" {
|
|
cfg["rotation"] = rotation
|
|
}
|
|
|
|
if len(cfg) == 0 {
|
|
return "", "", nil
|
|
}
|
|
|
|
configJSON, err := marshalTargetConfig(cfg)
|
|
|
|
return configJSON, "", err
|
|
}
|