Add a target edit form with headers and timeout fields (closes #127) (#229)
Some checks failed
check / check (push) Failing after 2m59s

This commit was merged in pull request #229.
This commit is contained in:
2026-08-20 07:24:12 +02:00
parent c6a9884f86
commit aba02bc509
11 changed files with 1764 additions and 44 deletions

View File

@@ -1029,9 +1029,7 @@ func (h *Handlers) processTargetCreate(
// Referer headers and error trackers record.
name := r.PostFormValue("name")
targetType := database.TargetType(r.PostFormValue("type"))
targetURL := r.PostFormValue("url")
maxRetriesStr := r.PostFormValue("max_retries")
expiry := r.PostFormValue("expiry")
if name == "" {
http.Error(
@@ -1051,7 +1049,7 @@ func (h *Handlers) processTargetCreate(
}
configJSON, err := h.buildTargetConfig(
w, r, targetType, targetURL, expiry,
w, r, targetType, targetFormInputFrom(r),
)
if err != nil {
return
@@ -1108,28 +1106,60 @@ func parseNonNegativeInt(s string) int {
return 0
}
// buildTargetConfig builds the JSON config string for a target.
// The expiry form value is read by the caller (which bounds the
// request body) and applies to database targets only.
// targetFormInput carries the raw form values describing a target's
// configuration. Both the create and the edit path fill one and hand
// it to buildTargetConfig, so neither can come to validate a
// destination differently from the other.
type targetFormInput struct {
// 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
// Expiry is a database (archive) target's row expiry.
Expiry string
}
// targetFormInputFrom reads the configuration fields 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 /source/{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{
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
Expiry: r.PostFormValue("expiry"),
}
}
// buildTargetConfig builds the JSON config string for a target from
// the submitted form values, writing its own 4xx response on
// rejection. Which fields of in apply depends on the target type.
func (h *Handlers) buildTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetType database.TargetType,
targetURL, expiry string,
in targetFormInput,
) (string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildURLTargetConfig(
w, r, targetURL, "url",
"URL is required for HTTP targets",
)
return h.buildHTTPTargetConfig(w, r, in)
case database.TargetTypeSlack:
return h.buildURLTargetConfig(
w, r, targetURL, "webhookUrl",
"Webhook URL is required for Slack targets",
)
return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, expiry)
return h.buildDatabaseTargetConfig(w, in.Expiry)
case database.TargetTypeLog:
return "", nil
default:
@@ -1142,14 +1172,83 @@ func (h *Handlers) buildTargetConfig(
}
}
// buildURLTargetConfig builds config JSON for a target whose
// configuration is a single SSRF-validated URL stored under
// configKey. missingMsg is the error shown when no URL is given.
func (h *Handlers) buildURLTargetConfig(
// 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(
w http.ResponseWriter,
r *http.Request,
targetURL, configKey, missingMsg string,
in targetFormInput,
) (string, error) {
err := h.validateTargetURL(
w, r, in.URL, "URL is required for HTTP targets",
)
if err != nil {
return "", err
}
headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil {
http.Error(
w,
"Invalid headers: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil {
http.Error(
w,
"Invalid timeout: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
})
}
// buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, error) {
err := h.validateTargetURL(
w, r, targetURL,
"Webhook URL is required for Slack targets",
)
if err != nil {
return "", err
}
return marshalTargetConfig(w, delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
}
// validateTargetURL rejects an empty or SSRF-blocked destination,
// writing the 400 itself. missingMsg is the error shown when no URL
// is given.
//
// 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(
w http.ResponseWriter,
r *http.Request,
targetURL, missingMsg string,
) error {
if targetURL == "" {
http.Error(
w,
@@ -1157,7 +1256,7 @@ func (h *Handlers) buildURLTargetConfig(
http.StatusBadRequest,
)
return "", errMissingURL
return errMissingURL
}
err := delivery.ValidateTargetURL(
@@ -1178,11 +1277,18 @@ func (h *Handlers) buildURLTargetConfig(
http.StatusBadRequest,
)
return "", err
return err
}
cfg := map[string]any{configKey: targetURL}
return nil
}
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func marshalTargetConfig(
w http.ResponseWriter,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
@@ -1222,19 +1328,9 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err
}
cfg := map[string]any{"expiry": expiry}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err
}
return string(configBytes), nil
return marshalTargetConfig(
w, map[string]any{"expiry": expiry},
)
}
// HandleEntrypointDelete handles deleting an entrypoint.