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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user