257 lines
6.7 KiB
Go
257 lines
6.7 KiB
Go
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)
|
|
}
|