Add a target edit form and reachable header/timeout fields (closes #127)
All checks were successful
check / check (push) Successful in 3m48s

A target's configuration was write-once: `internal/server/routes.go`
registered create, toggle and delete for targets but no edit route, so
correcting a typo in a destination meant deleting the target and
recreating it. Masking the stored value made that unrecoverable from
the UI.

Headers and timeout were worse than write-once. `HTTPTargetConfig` has
carried `Headers` and `Timeout` and the delivery path has honoured both,
but `buildURLTargetConfig` only ever wrote `{"url":...}` and no form
offered either field, so a destination needing an `Authorization` header
could not be configured through the UI at all.

Both paths now build their configuration through `buildTargetConfig`, so
an edited destination is SSRF-validated exactly as a new one is. The
destination check lives in one helper that both reach, leaving the guard's
entry point untouched.

The edit form pre-fills the stored destination and header values in full.
That is the one intentional exception to the masking rule, narrowed by
the route it lives on: `RequireAuth`, `NoCache`, and the webhook's
ownership check. `delivery.TargetView` is unchanged, so every other page
still masks.

A target's type stays fixed at creation: each type stores a different
configuration shape and its delivery history is recorded against the row,
so changing it is really a different target.

Header and timeout input that could not be delivered as written is
rejected rather than stored: a malformed line, an invalid name, a control
character in a value, a repeated name, a header the delivery engine
overwrites regardless, or a timeout that is not a whole number of seconds
within the ceiling. Storing input that provably never reaches the wire
would report a configuration that did not take effect.
This commit is contained in:
2026-08-20 04:24:27 +00:00
parent a13e5b7ded
commit 84878cd218
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.