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 "" }