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.
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package delivery
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// errUnknownTargetTypeForEdit is returned when a stored target has a
|
|
// type the edit form has no field set for.
|
|
var errUnknownTargetTypeForEdit = errors.New(
|
|
"unknown target type",
|
|
)
|
|
|
|
// TargetConfigForm is the UNMASKED projection of a target's stored
|
|
// configuration, for pre-filling the target edit form.
|
|
//
|
|
// It is the deliberate exception to the rule TargetView enforces
|
|
// everywhere else: TargetView exists so that no template can render
|
|
// a target's stored blob, because a destination URL's path segments
|
|
// and a header value are both routinely the credential. An operator
|
|
// cannot correct a value they cannot see, so the edit form — and
|
|
// only the edit form — is shown the full value.
|
|
//
|
|
// Everything that keeps that exception narrow lives at the call
|
|
// site: the route is behind RequireAuth and the webhook's ownership
|
|
// check, and its group sets NoCache so the rendered secret is not
|
|
// written to a shared cache. Do not reach for this type from any
|
|
// other page.
|
|
type TargetConfigForm struct {
|
|
// URL is the destination for an HTTP target and the webhook
|
|
// URL for a Slack target.
|
|
URL string
|
|
// Headers is the HTTP target's configured headers in the
|
|
// textarea representation, one "Name: value" per line.
|
|
Headers string
|
|
// Timeout is the HTTP target's per-request timeout in seconds,
|
|
// empty when unset.
|
|
Timeout string
|
|
// Expiry is the database (archive) target's row expiry.
|
|
Expiry string
|
|
}
|
|
|
|
// NewTargetConfigForm parses a target's stored configuration into
|
|
// the edit form's fields.
|
|
//
|
|
// A configuration that does not parse is an error rather than a
|
|
// zero-valued form that silently looks like a target with no
|
|
// settings. The caller shows the operator that the stored value
|
|
// could not be read, so that saving the form is understood as
|
|
// replacing it rather than preserving it.
|
|
func NewTargetConfigForm(
|
|
t *database.Target,
|
|
) (TargetConfigForm, error) {
|
|
switch t.Type {
|
|
case database.TargetTypeHTTP:
|
|
cfg, err := parseHTTPConfig(t.Config)
|
|
if err != nil {
|
|
return TargetConfigForm{}, err
|
|
}
|
|
|
|
return TargetConfigForm{
|
|
URL: cfg.URL,
|
|
Headers: FormatTargetHeaders(cfg.Headers),
|
|
Timeout: FormatTargetTimeout(cfg.Timeout),
|
|
}, nil
|
|
case database.TargetTypeSlack:
|
|
cfg, err := parseSlackConfig(t.Config)
|
|
if err != nil {
|
|
return TargetConfigForm{}, err
|
|
}
|
|
|
|
return TargetConfigForm{URL: cfg.WebhookURL}, nil
|
|
case database.TargetTypeDatabase:
|
|
return databaseConfigForm(t.Config)
|
|
case database.TargetTypeLog:
|
|
// The log target takes no configuration.
|
|
return TargetConfigForm{}, nil
|
|
default:
|
|
return TargetConfigForm{}, fmt.Errorf(
|
|
"%w: %q", errUnknownTargetTypeForEdit, t.Type,
|
|
)
|
|
}
|
|
}
|
|
|
|
// databaseConfigForm parses an archive target's optional expiry.
|
|
// An absent or empty configuration is the keep-forever default and
|
|
// yields an empty field, so re-saving the form unchanged stores the
|
|
// same empty configuration it started with. An expiry that is set
|
|
// but not a valid duration is an error, not a blank field.
|
|
func databaseConfigForm(
|
|
configJSON string,
|
|
) (TargetConfigForm, error) {
|
|
if configJSON == "" {
|
|
return TargetConfigForm{}, nil
|
|
}
|
|
|
|
var cfg databaseTargetConfig
|
|
|
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
|
if err != nil {
|
|
return TargetConfigForm{}, fmt.Errorf(
|
|
"parsing config JSON: %w", err,
|
|
)
|
|
}
|
|
|
|
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
|
return TargetConfigForm{}, nil
|
|
}
|
|
|
|
err = ValidateArchiveExpiry(cfg.Expiry)
|
|
if err != nil {
|
|
return TargetConfigForm{}, err
|
|
}
|
|
|
|
return TargetConfigForm{Expiry: cfg.Expiry}, nil
|
|
}
|