check / check (push) Successful in 3m14s
Pure code movement. Every declaration of internal/handlers/source_management.go moves unchanged into one of: webhook_list.go, webhook_create.go, webhook_detail.go, webhook_edit.go and webhook_delete.go for the webhook pages; event_log.go for the event log; entrypoint.go for the entrypoint handlers; target_create.go and target.go for the target handlers; and shared.go for the helpers several of them use. Only each file's package line and imports are new. 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
|
|
}
|