Validate max_retries on both target forms (closes #221)
All checks were successful
check / check (push) Successful in 3m26s

max_retries was read through parseNonNegativeInt, which returned 0 for
any parse failure. On the create form `abc`, `2.7`, `-5` and a
twenty-digit number were all accepted with HTTP 200 and stored as 0,
and `999999999` was stored verbatim with no ceiling. On the edit form
the same input destroyed a working retry configuration: a target
delivering with max_retries=2, re-saved with a typo in the field, was
silently left at 0 — fire-and-forget on a store-and-forward proxy,
with nothing said.

A value that is set but unparseable must be rejected loudly. A default
belongs only to an absent value. parseMaxRetries makes that
distinction explicit: an empty or omitted field yields the caller's
fallback (0 at creation, the stored count on edit), and anything else
that is not a whole number in range is a 400. Both forms go through
one validator, so they cannot come to disagree.

The ceiling is 20, which both target templates have always declared as
max="20" on the input; only the server never enforced it. Backoff is
2^(n-1) seconds, so attempt 20 is already about six days out, and each
attempt writes a delivery_results row the event log then loads and
renders.

The rejection wording matches the timeout control on the same
submission, which already got this right, and names the ceiling when
the value is out of range.

Existing rows above the ceiling are untouched: they still render on
the source and edit pages and still deliver. This is input validation,
not a migration.

parseNonNegativeInt is removed. Its other two callers read the log
page number for a post-action redirect, where falling back to page 1
is correct — it is navigation, not stored configuration, and the
action has already completed. They now use pageOrFirst, named for what
it does and shared with parsePage on the GET side, so no general
silently-coercing int parser is left for a configuration field to
reach for.
This commit is contained in:
2026-08-23 23:18:54 +00:00
parent 89f3b984d2
commit e65cb89d1c
7 changed files with 571 additions and 31 deletions

View File

@@ -905,16 +905,7 @@ func (h *Handlers) loadTargetMap(
// parsePage extracts a page number from the query string.
func (h *Handlers) parsePage(r *http.Request) int {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
v, err := strconv.Atoi(p)
if err == nil && v > 0 {
page = v
}
}
return page
return pageOrFirst(r.URL.Query().Get("page"))
}
// loadEventsWithDeliveries loads paginated events and their
@@ -1443,7 +1434,6 @@ func (h *Handlers) processTargetCreate(
// Referer headers and error trackers record.
name := r.PostFormValue("name")
targetType := database.TargetType(r.PostFormValue("type"))
maxRetriesStr := r.PostFormValue("max_retries")
if name == "" {
http.Error(
@@ -1469,7 +1459,14 @@ func (h *Handlers) processTargetCreate(
return
}
maxRetries := parseNonNegativeInt(maxRetriesStr)
// A new target has no stored retry count, so an absent field
// takes the fire-and-forget default. A field the operator filled
// in with something invalid is rejected rather than becoming
// that default.
maxRetries, ok := targetMaxRetries(w, r, 0)
if !ok {
return
}
target := &database.Target{
WebhookID: webhook.ID,
@@ -1505,19 +1502,22 @@ func isValidTargetType(tt database.TargetType) bool {
}
}
// parseNonNegativeInt parses s as a non-negative integer,
// returning 0 if s is empty or invalid.
func parseNonNegativeInt(s string) int {
if s == "" {
return 0
// pageOrFirst parses a paginated page number, answering 1 for
// anything empty, unparseable or out of range.
//
// Falling back rather than rejecting is correct here and only here:
// a page number is where to send the browser next, not configuration
// the operator is storing, and the actions that submit one have
// already completed by the time it is read — answering 400 would
// report a failure that did not happen. Anything an operator SETS
// must be validated instead; see parseMaxRetries.
func pageOrFirst(s string) int {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || v < 1 {
return 1
}
v, err := strconv.Atoi(s)
if err == nil && v >= 0 {
return v
}
return 0
return v
}
// targetFormInput carries the raw form values describing a target's