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 }