Add a target edit form with headers and timeout fields (closes #127) (#229)
Some checks failed
check / check (push) Failing after 2m59s
Some checks failed
check / check (push) Failing after 2m59s
This commit was merged in pull request #229.
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user