Add a target edit form and reachable header/timeout fields (closes #127) #229
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)
|
s.renderTemplate(w, r, pageTemplate, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSlackTargetConfigForTest exposes buildURLTargetConfig
|
// BuildSlackTargetConfigForTest exposes
|
||||||
// with the Slack target parameters for use in the
|
// buildSlackTargetConfig for use in the handlers_test package.
|
||||||
// handlers_test package.
|
|
||||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildURLTargetConfig(
|
return s.buildSlackTargetConfig(w, r, targetURL)
|
||||||
w, r, targetURL, "webhookUrl",
|
}
|
||||||
"Webhook URL is required for Slack targets",
|
|
||||||
)
|
// 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
|
// BuildDatabaseTargetConfigForTest exposes
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func New(
|
|||||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
|||||||
@@ -1029,9 +1029,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
// Referer headers and error trackers record.
|
// Referer headers and error trackers record.
|
||||||
name := r.PostFormValue("name")
|
name := r.PostFormValue("name")
|
||||||
targetType := database.TargetType(r.PostFormValue("type"))
|
targetType := database.TargetType(r.PostFormValue("type"))
|
||||||
targetURL := r.PostFormValue("url")
|
|
||||||
maxRetriesStr := r.PostFormValue("max_retries")
|
maxRetriesStr := r.PostFormValue("max_retries")
|
||||||
expiry := r.PostFormValue("expiry")
|
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -1051,7 +1049,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
configJSON, err := h.buildTargetConfig(
|
configJSON, err := h.buildTargetConfig(
|
||||||
w, r, targetType, targetURL, expiry,
|
w, r, targetType, targetFormInputFrom(r),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -1108,28 +1106,60 @@ func parseNonNegativeInt(s string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTargetConfig builds the JSON config string for a target.
|
// targetFormInput carries the raw form values describing a target's
|
||||||
// The expiry form value is read by the caller (which bounds the
|
// configuration. Both the create and the edit path fill one and hand
|
||||||
// request body) and applies to database targets only.
|
// 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(
|
func (h *Handlers) buildTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
targetURL, expiry string,
|
in targetFormInput,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
switch targetType {
|
switch targetType {
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return h.buildURLTargetConfig(
|
return h.buildHTTPTargetConfig(w, r, in)
|
||||||
w, r, targetURL, "url",
|
|
||||||
"URL is required for HTTP targets",
|
|
||||||
)
|
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildURLTargetConfig(
|
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||||
w, r, targetURL, "webhookUrl",
|
|
||||||
"Webhook URL is required for Slack targets",
|
|
||||||
)
|
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, expiry)
|
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
@@ -1142,14 +1172,83 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildURLTargetConfig builds config JSON for a target whose
|
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
|
||||||
// configuration is a single SSRF-validated URL stored under
|
// SSRF-validated destination plus the optional headers and timeout
|
||||||
// configKey. missingMsg is the error shown when no URL is given.
|
// the delivery path honours.
|
||||||
func (h *Handlers) buildURLTargetConfig(
|
func (h *Handlers) buildHTTPTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
targetURL, configKey, missingMsg string,
|
in targetFormInput,
|
||||||
) (string, error) {
|
) (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 == "" {
|
if targetURL == "" {
|
||||||
http.Error(
|
http.Error(
|
||||||
w,
|
w,
|
||||||
@@ -1157,7 +1256,7 @@ func (h *Handlers) buildURLTargetConfig(
|
|||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", errMissingURL
|
return errMissingURL
|
||||||
}
|
}
|
||||||
|
|
||||||
err := delivery.ValidateTargetURL(
|
err := delivery.ValidateTargetURL(
|
||||||
@@ -1178,11 +1277,18 @@ func (h *Handlers) buildURLTargetConfig(
|
|||||||
http.StatusBadRequest,
|
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)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -1222,19 +1328,9 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := map[string]any{"expiry": expiry}
|
return marshalTargetConfig(
|
||||||
|
w, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEntrypointDelete handles deleting an entrypoint.
|
// 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(),
|
s.h.HandleEntrypointToggle(),
|
||||||
)
|
)
|
||||||
r.Post("/targets", s.h.HandleTargetCreate())
|
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(
|
r.Post(
|
||||||
"/targets/{targetID}/delete",
|
"/targets/{targetID}/delete",
|
||||||
s.h.HandleTargetDelete(),
|
s.h.HandleTargetDelete(),
|
||||||
|
|||||||
@@ -110,6 +110,14 @@
|
|||||||
<div x-show="targetType === 'http'">
|
<div x-show="targetType === 'http'">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||||
</div>
|
</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">
|
<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>
|
<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">
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||||
@@ -138,6 +146,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{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">
|
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<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}}">
|
<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