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 case "Trailer": // net/http strips Trailer from the request it writes // (reqWriteExcludeHeader), so a configured one is accepted // and stored and then provably never reaches the wire. 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) { // Quotes nothing. The text before the first colon is only // a name if it parses as one; when it does not, it is as // likely to be a pasted value whose own colon split the // line, and half of a token would be echoed into the 400. return "", "", errHeaderNameInvalid } 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) }