Add a target edit form and reachable header/timeout fields (closes #127)
All checks were successful
check / check (push) Successful in 3m48s
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:
119
internal/delivery/target_config_edit.go
Normal file
119
internal/delivery/target_config_edit.go
Normal file
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
256
internal/delivery/target_headers.go
Normal file
256
internal/delivery/target_headers.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxTargetTimeoutSeconds bounds a per-target request timeout.
|
||||
// A delivery attempt holds a worker for its whole duration, so an
|
||||
// unbounded timeout lets one misconfigured target stall the queue
|
||||
// indefinitely. Five minutes is far beyond any healthy webhook
|
||||
// receiver and still finite.
|
||||
const MaxTargetTimeoutSeconds = 300
|
||||
|
||||
// Errors returned when a target's header or timeout form input
|
||||
// cannot be turned into a configuration.
|
||||
//
|
||||
// None of these ever quotes a header VALUE. A target header value
|
||||
// is routinely an authorization token, and these messages are shown
|
||||
// to the user in an error page body.
|
||||
var (
|
||||
errHeaderLineMalformed = errors.New(
|
||||
`each header line must be "Name: value"`,
|
||||
)
|
||||
errHeaderNameInvalid = errors.New(
|
||||
"header name must be a valid HTTP token",
|
||||
)
|
||||
errHeaderValueInvalid = errors.New(
|
||||
"header value must not contain control characters",
|
||||
)
|
||||
errHeaderDuplicate = errors.New(
|
||||
"header given more than once",
|
||||
)
|
||||
errHeaderReserved = errors.New(
|
||||
"header is set by the delivery engine and cannot be " +
|
||||
"overridden",
|
||||
)
|
||||
errTimeoutInvalid = errors.New(
|
||||
"timeout must be a whole number of seconds",
|
||||
)
|
||||
errTimeoutOutOfRange = errors.New(
|
||||
"timeout is out of range",
|
||||
)
|
||||
)
|
||||
|
||||
// isReservedTargetHeader reports whether name (canonicalised) is a
|
||||
// header a target configuration may not set, because the delivery
|
||||
// path or net/http itself writes it regardless.
|
||||
//
|
||||
// These are rejected rather than accepted-and-ignored. Storing a
|
||||
// header that provably never reaches the wire tells the operator
|
||||
// their configuration took effect when it did not, which is the
|
||||
// same failure mode as silently substituting a default for an
|
||||
// invalid value.
|
||||
func isReservedTargetHeader(name string) bool {
|
||||
switch name {
|
||||
case "Host", "Content-Length", "Transfer-Encoding", "Connection":
|
||||
return true
|
||||
case "User-Agent":
|
||||
// applyRequestHeaders sets the User-Agent after it applies
|
||||
// the configured headers, so a configured one would always
|
||||
// be overwritten.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTargetHeaders turns the target form's headers field — one
|
||||
// "Name: value" pair per line, blank lines ignored — into the map
|
||||
// stored in HTTPTargetConfig.Headers. Names are canonicalised, so a
|
||||
// name repeated in a different case is still a duplicate rather than
|
||||
// one pair silently overwriting the other.
|
||||
//
|
||||
// An input with no pairs yields an empty map, which omitempty drops
|
||||
// from the stored config: a target configured with no headers keeps
|
||||
// the same config JSON it had before this field existed.
|
||||
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
||||
headers := make(map[string]string)
|
||||
|
||||
for i, line := range strings.Split(raw, "\n") {
|
||||
lineNum := i + 1
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
name, value, err := parseHeaderLine(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("line %d: %w", lineNum, err)
|
||||
}
|
||||
|
||||
if _, dup := headers[name]; dup {
|
||||
return nil, fmt.Errorf(
|
||||
"line %d: %w: %q", lineNum,
|
||||
errHeaderDuplicate, name,
|
||||
)
|
||||
}
|
||||
|
||||
headers[name] = value
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// parseHeaderLine splits and validates one "Name: value" line,
|
||||
// returning the canonicalised name and the trimmed value.
|
||||
func parseHeaderLine(line string) (string, string, error) {
|
||||
rawName, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
return "", "", errHeaderLineMalformed
|
||||
}
|
||||
|
||||
rawName = strings.TrimSpace(rawName)
|
||||
if !validHeaderName(rawName) {
|
||||
return "", "", fmt.Errorf(
|
||||
"%w: %q", errHeaderNameInvalid, rawName,
|
||||
)
|
||||
}
|
||||
|
||||
name := http.CanonicalHeaderKey(rawName)
|
||||
if isReservedTargetHeader(name) {
|
||||
return "", "", fmt.Errorf(
|
||||
"%w: %q", errHeaderReserved, name,
|
||||
)
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
if !validHeaderValue(value) {
|
||||
return "", "", fmt.Errorf(
|
||||
"%w: %q", errHeaderValueInvalid, name,
|
||||
)
|
||||
}
|
||||
|
||||
return name, value, nil
|
||||
}
|
||||
|
||||
// validHeaderName reports whether name is a non-empty RFC 9110
|
||||
// field name. Rejecting anything else here is what keeps a value
|
||||
// containing CR or LF from being smuggled in as part of a name and
|
||||
// injecting a second header into the outbound request.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range len(name) {
|
||||
if !isTokenByte(name[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isTokenByte reports whether c is a "tchar" per RFC 9110 5.6.2.
|
||||
func isTokenByte(c byte) bool {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z',
|
||||
c >= 'A' && c <= 'Z',
|
||||
c >= '0' && c <= '9':
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0
|
||||
}
|
||||
|
||||
// validHeaderValue reports whether value is a legal field value:
|
||||
// no control characters, which is the other half of the header
|
||||
// injection guard. An empty value is legal.
|
||||
func validHeaderValue(value string) bool {
|
||||
for i := range len(value) {
|
||||
c := value[i]
|
||||
if c < 0x20 || c == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// FormatTargetHeaders renders a stored header map back into the
|
||||
// form's textarea representation, one "Name: value" per line.
|
||||
//
|
||||
// Names are sorted so that loading the edit form twice without
|
||||
// saving produces identical text; Go map iteration order would
|
||||
// otherwise reshuffle the field on every render.
|
||||
func FormatTargetHeaders(headers map[string]string) string {
|
||||
if len(headers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(headers))
|
||||
for name := range headers {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
slices.Sort(names)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
for _, name := range names {
|
||||
b.WriteString(name)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(headers[name])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ParseTargetTimeout interprets the target form's timeout field as
|
||||
// a whole number of seconds. An empty field means "unset" and yields
|
||||
// 0, which omitempty drops from the stored config and which the
|
||||
// delivery path reads as "use the shared client's timeout".
|
||||
//
|
||||
// Anything else that is not a whole number in range is an error, not
|
||||
// a silently substituted default: a target whose timeout was typed
|
||||
// wrong must say so at the form rather than deliver on a timeout its
|
||||
// operator did not choose.
|
||||
func ParseTargetTimeout(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return 0, errTimeoutInvalid
|
||||
}
|
||||
|
||||
if v > MaxTargetTimeoutSeconds {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: at most %d seconds",
|
||||
errTimeoutOutOfRange, MaxTargetTimeoutSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// FormatTargetTimeout renders a stored timeout for the form field.
|
||||
// An unset timeout renders as an empty field rather than "0", so the
|
||||
// placeholder can describe the default the target actually uses.
|
||||
func FormatTargetTimeout(timeout int) string {
|
||||
if timeout <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strconv.Itoa(timeout)
|
||||
}
|
||||
273
internal/delivery/target_headers_test.go
Normal file
273
internal/delivery/target_headers_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// Literals these tests repeat, named so that the header name and the
|
||||
// keep-forever archive config each have one definition.
|
||||
const (
|
||||
headerAuthorization = "Authorization"
|
||||
bearerValue = "Bearer abc"
|
||||
archiveConfigNever = "{\"expiry\":\"never\"}"
|
||||
)
|
||||
|
||||
func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := delivery.ParseTargetHeaders(
|
||||
" Authorization: Bearer abc \n\n" +
|
||||
"x-tenant:acme\r\n" +
|
||||
"X-Empty:\n",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
headerAuthorization: bearerValue,
|
||||
"X-Tenant": "acme",
|
||||
"X-Empty": "",
|
||||
},
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
// A configuration with no headers must stay indistinguishable from
|
||||
// one written before the field existed, so omitempty drops the key.
|
||||
func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := delivery.ParseTargetHeaders("\n \n")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
|
||||
encoded, err := json.Marshal(delivery.HTTPTargetConfig{
|
||||
URL: "https://example.com/h",
|
||||
Headers: got,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(
|
||||
t, `{"url":"https://example.com/h"}`, string(encoded),
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseTargetHeaders_Rejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"no colon": "Authorization Bearer abc",
|
||||
"empty name": ": value",
|
||||
"space in name": "X Bad: value",
|
||||
"reserved host": "Host: evil.example",
|
||||
"reserved ua": "User-Agent: curl/8",
|
||||
"reserved length": "Content-Length: 0",
|
||||
"duplicate any case": "X-A: 1\nx-a: 2",
|
||||
}
|
||||
|
||||
for name, input := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := delivery.ParseTargetHeaders(input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A header value is routinely a bearer token and these errors are
|
||||
// rendered into a 400 body, so no message may quote one.
|
||||
func TestParseTargetHeaders_ErrorsNeverQuoteAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "QQNEVERINAMESSAGEQQ"
|
||||
|
||||
_, err := delivery.ParseTargetHeaders(
|
||||
"X-A: " + secret + "\nx-a: " + secret,
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), secret)
|
||||
|
||||
_, err = delivery.ParseTargetHeaders(
|
||||
"X Bad Name: " + secret,
|
||||
)
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), secret)
|
||||
}
|
||||
|
||||
// Loading the edit form twice without saving must not reshuffle
|
||||
// the textarea, which Go's map iteration order would otherwise do.
|
||||
func TestFormatTargetHeaders_IsSorted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := delivery.FormatTargetHeaders(map[string]string{
|
||||
"X-Zed": "z",
|
||||
headerAuthorization: bearerValue,
|
||||
"X-Alpha": "a",
|
||||
})
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"Authorization: Bearer abc\nX-Alpha: a\nX-Zed: z\n",
|
||||
got,
|
||||
)
|
||||
assert.Empty(t, delivery.FormatTargetHeaders(nil))
|
||||
}
|
||||
|
||||
func TestFormatTargetHeaders_RoundTripsThroughParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := map[string]string{
|
||||
headerAuthorization: bearerValue,
|
||||
"X-Tenant": "acme",
|
||||
}
|
||||
|
||||
got, err := delivery.ParseTargetHeaders(
|
||||
delivery.FormatTargetHeaders(want),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestParseTargetTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := delivery.ParseTargetTimeout(" 30 ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 30, got)
|
||||
|
||||
got, err = delivery.ParseTargetTimeout("")
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, got)
|
||||
|
||||
for _, bad := range []string{"soon", "-1", "1e3", "100000"} {
|
||||
_, err = delivery.ParseTargetTimeout(bad)
|
||||
require.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTargetTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "30", delivery.FormatTargetTimeout(30))
|
||||
assert.Empty(t, delivery.FormatTargetTimeout(0))
|
||||
assert.Empty(t, delivery.FormatTargetTimeout(-1))
|
||||
}
|
||||
|
||||
func TestNewTargetConfigForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
form, err := delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"https://example.com/h",` +
|
||||
`"headers":{"Authorization":"Bearer abc"},` +
|
||||
`"timeout":9}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/h", form.URL)
|
||||
assert.Equal(t, "Authorization: Bearer abc\n", form.Headers)
|
||||
assert.Equal(t, "9", form.Timeout)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"https://hooks.example/s"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://hooks.example/s", form.URL)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"720h"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "720h", form.Expiry)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeLog,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, form.URL)
|
||||
}
|
||||
|
||||
// A keep-forever archive target must pre-fill as an empty field, so
|
||||
// saving the form back unchanged stores the same empty config.
|
||||
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cfg := range []string{"", `{}`, archiveConfigNever} {
|
||||
form, err := delivery.NewTargetConfigForm(
|
||||
&database.Target{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: cfg,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err, cfg)
|
||||
assert.Empty(t, form.Expiry, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable stored config is an error rather than a blank form
|
||||
// that looks like a target with no settings, so the caller can tell
|
||||
// the operator that saving replaces the stored value.
|
||||
func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []*database.Target{
|
||||
{Type: database.TargetTypeHTTP, Config: "not json"},
|
||||
{Type: database.TargetTypeHTTP, Config: `{}`},
|
||||
{Type: database.TargetTypeSlack, Config: ""},
|
||||
{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"soon"}`,
|
||||
},
|
||||
{Type: database.TargetType("nope")},
|
||||
}
|
||||
|
||||
for _, target := range cases {
|
||||
_, err := delivery.NewTargetConfigForm(target)
|
||||
require.Error(t, err, target.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The ceiling exists so one misconfigured target cannot hold a
|
||||
// delivery worker indefinitely, and it is inclusive.
|
||||
func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Positive(t, delivery.MaxTargetTimeoutSeconds)
|
||||
|
||||
got, err := delivery.ParseTargetTimeout(
|
||||
strconv.Itoa(delivery.MaxTargetTimeoutSeconds),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, delivery.MaxTargetTimeoutSeconds, got)
|
||||
|
||||
_, err = delivery.ParseTargetTimeout(
|
||||
strconv.Itoa(delivery.MaxTargetTimeoutSeconds + 1),
|
||||
)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Control characters in a value are how a second header would be
|
||||
// smuggled into the outbound request.
|
||||
func TestParseTargetHeaders_RejectsControlCharactersInValues(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, bad := range []string{
|
||||
"X-A: one\x01two",
|
||||
"X-A: one\ttwo",
|
||||
"X-A: one\x7ftwo",
|
||||
} {
|
||||
_, err := delivery.ParseTargetHeaders(bad)
|
||||
require.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
@@ -69,18 +69,29 @@ func (s *Handlers) RenderTemplateForTest(
|
||||
s.renderTemplate(w, r, pageTemplate, data)
|
||||
}
|
||||
|
||||
// BuildSlackTargetConfigForTest exposes buildURLTargetConfig
|
||||
// with the Slack target parameters for use in the
|
||||
// handlers_test package.
|
||||
// BuildSlackTargetConfigForTest exposes
|
||||
// buildSlackTargetConfig for use in the handlers_test package.
|
||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL string,
|
||||
) (string, error) {
|
||||
return s.buildURLTargetConfig(
|
||||
w, r, targetURL, "webhookUrl",
|
||||
"Webhook URL is required for Slack targets",
|
||||
)
|
||||
return s.buildSlackTargetConfig(w, r, targetURL)
|
||||
}
|
||||
|
||||
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
||||
// for use in the handlers_test package, taking the form fields
|
||||
// an HTTP target's configuration is built from.
|
||||
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
targetURL, headers, timeout string,
|
||||
) (string, error) {
|
||||
return s.buildHTTPTargetConfig(w, r, targetFormInput{
|
||||
URL: targetURL,
|
||||
Headers: headers,
|
||||
Timeout: timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// BuildDatabaseTargetConfigForTest exposes
|
||||
|
||||
@@ -124,6 +124,7 @@ func New(
|
||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
|
||||
@@ -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 marshalTargetConfig(
|
||||
w, map[string]any{"expiry": expiry},
|
||||
)
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(configBytes), nil
|
||||
}
|
||||
|
||||
// HandleEntrypointDelete handles deleting an entrypoint.
|
||||
|
||||
221
internal/handlers/target_edit.go
Normal file
221
internal/handlers/target_edit.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// targetEditTemplate is the page the target edit form renders.
|
||||
const targetEditTemplate = "target_edit.html"
|
||||
|
||||
// tmplKeyTarget is the template data key for the target being
|
||||
// edited, and tmplKeyMaxTimeout for the timeout ceiling the form
|
||||
// tells the user about.
|
||||
const (
|
||||
tmplKeyTarget = "Target"
|
||||
tmplKeyMaxTimeout = "MaxTimeout"
|
||||
)
|
||||
|
||||
// configUnreadableMessage is shown when a target's stored
|
||||
// configuration does not parse. It says plainly that saving replaces
|
||||
// the stored value rather than preserving it, because the form
|
||||
// cannot pre-fill what it could not read.
|
||||
const configUnreadableMessage = "The stored configuration for this " +
|
||||
"target could not be read. Enter the values below; saving " +
|
||||
"replaces the stored configuration."
|
||||
|
||||
// targetEditView is the display model for the target edit page.
|
||||
//
|
||||
// It carries the target's row fields alongside its UNMASKED
|
||||
// configuration, and deliberately omits database.Target's raw
|
||||
// Config blob: the form renders named fields, and giving the
|
||||
// template the blob as well would put an unreviewed second path to
|
||||
// the credential on the page.
|
||||
type targetEditView struct {
|
||||
ID string
|
||||
Name string
|
||||
Type database.TargetType
|
||||
Active bool
|
||||
MaxRetries int
|
||||
Config delivery.TargetConfigForm
|
||||
}
|
||||
|
||||
// HandleTargetEdit shows the form to edit a target.
|
||||
//
|
||||
// This page is the one place the full destination URL and header
|
||||
// values are shown. It is reachable only through the
|
||||
// /source/{sourceID} route group, which supplies RequireAuth and
|
||||
// NoCache, and only for a target of a webhook the session's user
|
||||
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
||||
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
webhook, target, ok := h.ownedTarget(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := delivery.NewTargetConfigForm(target)
|
||||
msg := ""
|
||||
|
||||
if err != nil {
|
||||
// The error carries the parse failure, never the
|
||||
// blob, so it is safe to log against the target id.
|
||||
h.log.Warn(
|
||||
"stored target config could not be read for editing",
|
||||
"target_id", target.ID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
msg = configUnreadableMessage
|
||||
}
|
||||
|
||||
h.renderTargetEdit(w, r, webhook, target, cfg, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTargetEditSubmit handles the target edit form submission.
|
||||
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
webhook, target, ok := h.ownedTarget(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// The body size cap is enforced by the MaxBodySize
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.applyTargetEdit(w, r, webhook, target)
|
||||
}
|
||||
}
|
||||
|
||||
// applyTargetEdit validates and saves target edits.
|
||||
//
|
||||
// The submitted configuration goes through buildTargetConfig, the
|
||||
// same builder the create path uses, so an edited destination is
|
||||
// SSRF-validated exactly as a new one is.
|
||||
//
|
||||
// The target's type is not editable. Each type stores a different
|
||||
// configuration shape and its delivery history is recorded against
|
||||
// the target row, so changing the type of an existing target is
|
||||
// really the creation of a different one. The stored type decides
|
||||
// which fields the form offers and which builder runs.
|
||||
func (h *Handlers) applyTargetEdit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
target *database.Target,
|
||||
) {
|
||||
name := r.PostFormValue("name")
|
||||
if name == "" {
|
||||
http.Error(
|
||||
w, "Name is required", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
configJSON, err := h.buildTargetConfig(
|
||||
w, r, target.Type, targetFormInputFrom(r),
|
||||
)
|
||||
if err != nil {
|
||||
// buildTargetConfig has already written the response.
|
||||
return
|
||||
}
|
||||
|
||||
target.Name = name
|
||||
target.Config = configJSON
|
||||
|
||||
// Retries are offered only by the forms for target types that
|
||||
// retry, so an absent field means "this form does not edit
|
||||
// retries" rather than "set them to zero". Reading it
|
||||
// unconditionally would silently disable retries on any target
|
||||
// saved from a form that does not render the input.
|
||||
if r.PostForm.Has("max_retries") {
|
||||
target.MaxRetries = parseNonNegativeInt(
|
||||
r.PostFormValue("max_retries"),
|
||||
)
|
||||
}
|
||||
|
||||
err = h.db.DB().Save(target).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to update target", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
// renderTargetEdit renders the target edit page with an optional
|
||||
// error message.
|
||||
func (h *Handlers) renderTargetEdit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
target *database.Target,
|
||||
cfg delivery.TargetConfigForm,
|
||||
errMsg string,
|
||||
) {
|
||||
// The template calls Webhook methods, which take pointer
|
||||
// receivers; html/template cannot address a value stored in a
|
||||
// map.
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
tmplKeyTarget: targetEditView{
|
||||
ID: target.ID,
|
||||
Name: target.Name,
|
||||
Type: target.Type,
|
||||
Active: target.Active,
|
||||
MaxRetries: target.MaxRetries,
|
||||
Config: cfg,
|
||||
},
|
||||
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
||||
tmplKeyError: errMsg,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, targetEditTemplate, data)
|
||||
}
|
||||
|
||||
// ownedTarget resolves the request's sourceID and targetID
|
||||
// parameters to a target of a webhook the session's user owns.
|
||||
//
|
||||
// Ownership is decided by the webhook, and the target is then
|
||||
// scoped to that webhook, so a target id belonging to someone
|
||||
// else's webhook is a 404 rather than an edit of their target. It
|
||||
// reports false once it has written the response.
|
||||
func (h *Handlers) ownedTarget(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (database.Webhook, *database.Target, bool) {
|
||||
webhook, ok := h.ownedWebhook(w, r)
|
||||
if !ok {
|
||||
return database.Webhook{}, nil, false
|
||||
}
|
||||
|
||||
var target database.Target
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"id = ? AND webhook_id = ?",
|
||||
chi.URLParam(r, "targetID"), webhook.ID,
|
||||
).First(&target).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return database.Webhook{}, nil, false
|
||||
}
|
||||
|
||||
return webhook, &target, true
|
||||
}
|
||||
637
internal/handlers/target_edit_test.go
Normal file
637
internal/handlers/target_edit_test.go
Normal file
@@ -0,0 +1,637 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// The destinations the target edit tests configure. Both are literal
|
||||
// public addresses rather than hostnames so the SSRF check resolves
|
||||
// nothing: with a hostname, a sandbox without DNS would reject the
|
||||
// URL for the wrong reason and a test asserting rejection would pass
|
||||
// even with the guard removed.
|
||||
const (
|
||||
editOriginalURL = "https://93.184.216.34/hooks/original"
|
||||
editReplacedURL = "https://93.184.216.34/hooks/replaced"
|
||||
// editBlockedURL resolves to loopback, which the SSRF guard
|
||||
// refuses. It is what proves the guard runs on the edit path.
|
||||
editBlockedURL = "http://127.0.0.1/hooks/internal"
|
||||
)
|
||||
|
||||
// editAuthHeader carries a bearer credential, the case the headers
|
||||
// field exists for.
|
||||
const (
|
||||
editBearerSecret = "QQEDITSECRETQQ"
|
||||
editAuthHeader = "Authorization: Bearer " + editBearerSecret
|
||||
)
|
||||
|
||||
// targetRouter mounts the target create and edit routes on a chi
|
||||
// router so the handlers see the URL parameters they read.
|
||||
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
||||
router := chi.NewRouter()
|
||||
router.Post(
|
||||
"/source/{sourceID}/targets",
|
||||
env.handlers.HandleTargetCreate(),
|
||||
)
|
||||
router.Get(
|
||||
"/source/{sourceID}/targets/{targetID}/edit",
|
||||
env.handlers.HandleTargetEdit(),
|
||||
)
|
||||
router.Post(
|
||||
"/source/{sourceID}/targets/{targetID}/edit",
|
||||
env.handlers.HandleTargetEditSubmit(),
|
||||
)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// serveTarget drives one request through the target routes as the
|
||||
// authenticated test user.
|
||||
func serveTarget(
|
||||
env *sourceTestEnv,
|
||||
method, path string,
|
||||
form url.Values,
|
||||
) *httptest.ResponseRecorder {
|
||||
body := ""
|
||||
if form != nil {
|
||||
body = form.Encode()
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), method, path,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
if form != nil {
|
||||
req.Header.Set(
|
||||
"Content-Type",
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
}
|
||||
|
||||
for _, c := range env.cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
targetRouter(env).ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// seedHTTPTarget creates a webhook and an HTTP target on it through
|
||||
// the real create handler, so every case starts from a target the
|
||||
// production path produced rather than a hand-written row.
|
||||
//
|
||||
// Standing the fx app up is what a handler test mostly costs, and
|
||||
// internal/handlers is already the slowest package in the suite, so
|
||||
// the tests below share one env per test function and give each case
|
||||
// its own webhook rather than its own app.
|
||||
func seedHTTPTarget(
|
||||
t *testing.T,
|
||||
env *sourceTestEnv,
|
||||
headers, timeout string,
|
||||
) (database.Webhook, database.Target) {
|
||||
t.Helper()
|
||||
|
||||
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "original-name")
|
||||
form.Set("type", string(database.TargetTypeHTTP))
|
||||
form.Set("url", editOriginalURL)
|
||||
form.Set("headers", headers)
|
||||
form.Set("timeout", timeout)
|
||||
form.Set("max_retries", "3")
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhook.ID+"/targets", form,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||
require.Len(t, targets, 1)
|
||||
|
||||
return webhook, targets[0]
|
||||
}
|
||||
|
||||
// storedTarget reloads a target row.
|
||||
func storedTarget(
|
||||
t *testing.T,
|
||||
env *sourceTestEnv,
|
||||
targetID string,
|
||||
) database.Target {
|
||||
t.Helper()
|
||||
|
||||
var target database.Target
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
env.db.DB().Where("id = ?", targetID).
|
||||
First(&target).Error,
|
||||
)
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// storedHTTPConfig reloads a target and parses its stored HTTP
|
||||
// configuration.
|
||||
func storedHTTPConfig(
|
||||
t *testing.T,
|
||||
env *sourceTestEnv,
|
||||
targetID string,
|
||||
) delivery.HTTPTargetConfig {
|
||||
t.Helper()
|
||||
|
||||
var cfg delivery.HTTPTargetConfig
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
json.Unmarshal(
|
||||
[]byte(storedTarget(t, env, targetID).Config), &cfg,
|
||||
),
|
||||
)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// editForm is the fully populated edit submission for an HTTP
|
||||
// target.
|
||||
func editForm(targetURL, headers, timeout string) url.Values {
|
||||
form := url.Values{}
|
||||
form.Set("name", "edited-name")
|
||||
form.Set("url", targetURL)
|
||||
form.Set("headers", headers)
|
||||
form.Set("timeout", timeout)
|
||||
form.Set("max_retries", "5")
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// submitTargetEdit posts the edit form for a target.
|
||||
func submitTargetEdit(
|
||||
env *sourceTestEnv,
|
||||
webhookID, targetID string,
|
||||
form url.Values,
|
||||
) *httptest.ResponseRecorder {
|
||||
return serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhookID+"/targets/"+targetID+"/edit",
|
||||
form,
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetCreate_Configuration covers the half of the gap
|
||||
// that is not about editing at all: HTTPTargetConfig has carried
|
||||
// Headers and Timeout, and the delivery path has honoured them, but
|
||||
// the create form wrote {"url":...} and nothing else, so a
|
||||
// destination needing an Authorization header could not be
|
||||
// configured through the UI at all.
|
||||
func TestHandleTargetCreate_Configuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
t.Run("stores headers and timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertCreateStoresHeadersAndTimeout(t, env)
|
||||
})
|
||||
|
||||
t.Run("without them keeps a url-only config", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertCreateKeepsURLOnlyConfig(t, env)
|
||||
})
|
||||
}
|
||||
|
||||
func assertCreateStoresHeadersAndTimeout(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
_, target := seedHTTPTarget(
|
||||
t, env, editAuthHeader+"\nX-Tenant: acme\n", "12",
|
||||
)
|
||||
|
||||
cfg := storedHTTPConfig(t, env, target.ID)
|
||||
|
||||
assert.Equal(t, editOriginalURL, cfg.URL)
|
||||
assert.Equal(t, 12, cfg.Timeout)
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + editBearerSecret,
|
||||
"X-Tenant": "acme",
|
||||
},
|
||||
cfg.Headers,
|
||||
)
|
||||
}
|
||||
|
||||
// Without the new fields the stored shape must be the same
|
||||
// {"url":...} the create form wrote before they existed, so no
|
||||
// existing target's configuration is rewritten by this change.
|
||||
func assertCreateKeepsURLOnlyConfig(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
_, target := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
assert.JSONEq(
|
||||
t, `{"url":"`+editOriginalURL+`"}`, target.Config,
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetEditSubmit_Saves is the round trip the issue asks
|
||||
// for: create a target, edit it, and confirm the stored config
|
||||
// changed.
|
||||
func TestHandleTargetEditSubmit_Saves(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
t.Run("changes the destination URL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditChangesDestination(t, env)
|
||||
})
|
||||
|
||||
t.Run("round trips headers and timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditRoundTripsHeadersAndTimeout(t, env)
|
||||
})
|
||||
|
||||
t.Run("clearing them removes them", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditClearingRemovesThem(t, env)
|
||||
})
|
||||
|
||||
t.Run("absent max_retries is not zeroed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditKeepsAbsentMaxRetries(t, env)
|
||||
})
|
||||
}
|
||||
|
||||
func assertEditChangesDestination(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
w := submitTargetEdit(
|
||||
env, webhook.ID, target.ID,
|
||||
editForm(editReplacedURL, "", ""),
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
editReplacedURL,
|
||||
storedHTTPConfig(t, env, target.ID).URL,
|
||||
)
|
||||
|
||||
reloaded := storedTarget(t, env, target.ID)
|
||||
assert.Equal(t, "edited-name", reloaded.Name)
|
||||
assert.Equal(t, 5, reloaded.MaxRetries)
|
||||
assert.Equal(
|
||||
t, database.TargetTypeHTTP, reloaded.Type,
|
||||
"the edit form must not change a target's type",
|
||||
)
|
||||
}
|
||||
|
||||
// The two previously unreachable fields must survive create,
|
||||
// pre-fill and save.
|
||||
func assertEditRoundTripsHeadersAndTimeout(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, editAuthHeader, "7")
|
||||
|
||||
w := submitTargetEdit(
|
||||
env, webhook.ID, target.ID,
|
||||
editForm(
|
||||
editOriginalURL,
|
||||
"Authorization: Bearer rotated\nX-Trace: on",
|
||||
"21",
|
||||
),
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
cfg := storedHTTPConfig(t, env, target.ID)
|
||||
|
||||
assert.Equal(t, 21, cfg.Timeout)
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Authorization": "Bearer rotated",
|
||||
"X-Trace": "on",
|
||||
},
|
||||
cfg.Headers,
|
||||
)
|
||||
}
|
||||
|
||||
// The direction a naive "only set what was submitted" implementation
|
||||
// gets wrong: an emptied field must remove the stored value, not
|
||||
// leave the previous one in place.
|
||||
func assertEditClearingRemovesThem(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, editAuthHeader, "7")
|
||||
|
||||
w := submitTargetEdit(
|
||||
env, webhook.ID, target.ID,
|
||||
editForm(editOriginalURL, "", ""),
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
cfg := storedHTTPConfig(t, env, target.ID)
|
||||
|
||||
assert.Empty(t, cfg.Headers)
|
||||
assert.Zero(t, cfg.Timeout)
|
||||
}
|
||||
|
||||
// Retries are offered only by the forms for target types that retry.
|
||||
// An absent field means the form does not edit retries, not that
|
||||
// they should be turned off.
|
||||
func assertEditKeepsAbsentMaxRetries(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, "", "")
|
||||
require.Equal(t, 3, target.MaxRetries)
|
||||
|
||||
form := editForm(editOriginalURL, "", "")
|
||||
form.Del("max_retries")
|
||||
|
||||
w := submitTargetEdit(env, webhook.ID, target.ID, form)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
assert.Equal(
|
||||
t, 3, storedTarget(t, env, target.ID).MaxRetries,
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked covers the
|
||||
// deliberate exception to the masking rule. The operator cannot
|
||||
// correct a value they cannot see, so this page — and only this page
|
||||
// — renders the destination and the header values in full.
|
||||
func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, editAuthHeader, "7")
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
page := w.Body.String()
|
||||
|
||||
assert.Contains(t, page, editOriginalURL)
|
||||
assert.Contains(t, page, "Bearer "+editBearerSecret)
|
||||
assert.Contains(t, page, `value="7"`)
|
||||
assert.Contains(t, page, "original-name")
|
||||
}
|
||||
|
||||
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
||||
// must not reach storage.
|
||||
//
|
||||
// The SSRF case is the most important assertion on this change: the
|
||||
// edited destination goes through the same guard the create path
|
||||
// uses. An edit that stored an unvalidated URL would reopen a closed
|
||||
// hole, since a target could then be created public and edited to
|
||||
// point at loopback.
|
||||
//
|
||||
// The header and timeout cases keep input that could not be
|
||||
// delivered as written out of storage: a stored value that provably
|
||||
// never reaches the wire reports a configuration that did not take
|
||||
// effect.
|
||||
func TestHandleTargetEditSubmit_Rejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
t.Run("an SSRF-blocked destination", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditRejectsBlockedDestination(t, env)
|
||||
})
|
||||
|
||||
t.Run("a query-string destination", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditIgnoresQueryString(t, env)
|
||||
})
|
||||
|
||||
headerCases := map[string]string{
|
||||
"no colon": "Authorization Bearer token",
|
||||
"empty name": ": value",
|
||||
"invalid name": "X Bad Name: value",
|
||||
"reserved header": "User-Agent: curl/8",
|
||||
"duplicate name": "X-A: one\nx-a: two",
|
||||
}
|
||||
|
||||
for name, headers := range headerCases {
|
||||
t.Run("headers: "+name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditRejectsHeaders(t, env, headers)
|
||||
})
|
||||
}
|
||||
|
||||
timeoutCases := map[string]string{
|
||||
"not a number": "soon",
|
||||
"negative": "-1",
|
||||
"over ceiling": "100000",
|
||||
}
|
||||
|
||||
for name, timeout := range timeoutCases {
|
||||
t.Run("timeout: "+name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertEditRejectsTimeout(t, env, timeout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEditRejectsBlockedDestination(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
w := submitTargetEdit(
|
||||
env, webhook.ID, target.ID,
|
||||
editForm(editBlockedURL, "", ""),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid target URL")
|
||||
assert.Equal(
|
||||
t, editOriginalURL,
|
||||
storedHTTPConfig(t, env, target.ID).URL,
|
||||
"a rejected edit must leave the stored config alone",
|
||||
)
|
||||
}
|
||||
|
||||
// The ingress rule the create path already follows applies to the
|
||||
// edit path too: reading a field with FormValue would let the request
|
||||
// line carry the credential, and the request line is what logs,
|
||||
// proxies and Referer headers record.
|
||||
func assertEditIgnoresQueryString(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "edited-name")
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/source/"+webhook.ID+"/targets/"+target.ID+
|
||||
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
||||
"&headers="+url.QueryEscape(editAuthHeader),
|
||||
form,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
cfg := storedHTTPConfig(t, env, target.ID)
|
||||
assert.Equal(t, editOriginalURL, cfg.URL)
|
||||
assert.Empty(t, cfg.Headers)
|
||||
}
|
||||
|
||||
func assertEditRejectsHeaders(
|
||||
t *testing.T, env *sourceTestEnv, headers string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
w := submitTargetEdit(
|
||||
env, webhook.ID, target.ID,
|
||||
editForm(editOriginalURL, headers, ""),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid headers")
|
||||
assert.Empty(
|
||||
t, storedHTTPConfig(t, env, target.ID).Headers,
|
||||
"a rejected header must not be stored",
|
||||
)
|
||||
}
|
||||
|
||||
func assertEditRejectsTimeout(
|
||||
t *testing.T, env *sourceTestEnv, timeout string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhook, target := seedHTTPTarget(t, env, "", "9")
|
||||
|
||||
w := submitTargetEdit(
|
||||
env, webhook.ID, target.ID,
|
||||
editForm(editOriginalURL, "", timeout),
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid timeout")
|
||||
assert.Equal(
|
||||
t, 9, storedHTTPConfig(t, env, target.ID).Timeout,
|
||||
"a rejected timeout must leave the stored one alone",
|
||||
)
|
||||
}
|
||||
|
||||
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
|
||||
// the delete and toggle routes are: ownership is decided by the
|
||||
// webhook, and the target is then scoped to it.
|
||||
func TestHandleTargetEdit_Scoping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupSourceTest(t)
|
||||
|
||||
t.Run("a target of another webhook", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertTargetOfAnotherWebhook404s(t, env)
|
||||
})
|
||||
|
||||
t.Run("a webhook of another user", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertWebhookOfAnotherUser404s(t, env)
|
||||
})
|
||||
}
|
||||
|
||||
// A target id from elsewhere must not become editable by pairing it
|
||||
// with a webhook the user does own.
|
||||
func assertTargetOfAnotherWebhook404s(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
mine := seedWebhookWithRetention(t, env.db, 30)
|
||||
_, target := seedHTTPTarget(t, env, "", "")
|
||||
|
||||
get := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNotFound, get.Code)
|
||||
|
||||
post := submitTargetEdit(
|
||||
env, mine.ID, target.ID,
|
||||
editForm(editReplacedURL, "", ""),
|
||||
)
|
||||
assert.Equal(t, http.StatusNotFound, post.Code)
|
||||
|
||||
assert.Equal(
|
||||
t, editOriginalURL,
|
||||
storedHTTPConfig(t, env, target.ID).URL,
|
||||
)
|
||||
}
|
||||
|
||||
func assertWebhookOfAnotherUser404s(
|
||||
t *testing.T, env *sourceTestEnv,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
other := &database.Webhook{
|
||||
UserID: "some-other-user",
|
||||
Name: "not mine",
|
||||
RetentionDays: 30,
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
env.db.DB().Omit(clause.Associations).Create(other).Error,
|
||||
)
|
||||
|
||||
target := seedConfiguredTarget(
|
||||
t, env.db, other.ID, database.TargetTypeHTTP,
|
||||
`{"url":"`+editOriginalURL+`"}`,
|
||||
)
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -214,6 +214,20 @@ func (s *Server) setupSourceRoutes() {
|
||||
s.h.HandleEntrypointToggle(),
|
||||
)
|
||||
r.Post("/targets", s.h.HandleTargetCreate())
|
||||
// The edit form is the one page that renders a target's
|
||||
// destination URL and header values in full; see
|
||||
// delivery.TargetConfigForm. It belongs to this group for
|
||||
// its RequireAuth and NoCache, which are what keep that
|
||||
// exception from reaching an unauthenticated request or a
|
||||
// shared cache.
|
||||
r.Get(
|
||||
"/targets/{targetID}/edit",
|
||||
s.h.HandleTargetEdit(),
|
||||
)
|
||||
r.Post(
|
||||
"/targets/{targetID}/edit",
|
||||
s.h.HandleTargetEditSubmit(),
|
||||
)
|
||||
r.Post(
|
||||
"/targets/{targetID}/delete",
|
||||
s.h.HandleTargetDelete(),
|
||||
|
||||
@@ -110,6 +110,14 @@
|
||||
<div x-show="targetType === 'http'">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
|
||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||
@@ -138,6 +146,7 @@
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||
|
||||
83
templates/target_edit.html
Normal file
83
templates/target_edit.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Edit {{.Target.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
{{if .Error}}
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
<div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700">
|
||||
This form shows the target's stored destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{.Target.Name}}" required class="input">
|
||||
</div>
|
||||
|
||||
{{if eq .Target.Type "http"}}
|
||||
<div class="form-group">
|
||||
<label for="url" class="label">Destination URL</label>
|
||||
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="headers" class="label">Headers</label>
|
||||
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.Target.Config.Headers}}</textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="timeout" class="label">Timeout (seconds)</label>
|
||||
<input type="number" id="timeout" name="timeout" value="{{.Target.Config.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Target.Type "slack"}}
|
||||
<div class="form-group">
|
||||
<label for="url" class="label">Webhook URL</label>
|
||||
<input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Target.Type "database"}}
|
||||
<div class="form-group">
|
||||
<label for="expiry" class="label">Archive Expiry</label>
|
||||
<input type="text" id="expiry" name="expiry" value="{{.Target.Config.Expiry}}" placeholder="never" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">"never" (the default when blank) keeps archived rows forever, or a Go duration like "720h" prunes older rows.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
<div class="form-group">
|
||||
<label for="max_retries" class="label">Max retries</label>
|
||||
<input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input">
|
||||
<p class="text-xs text-gray-500 mt-1">0 is fire-and-forget: one attempt, no circuit breaker.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user