max_retries is unbounded at target creation, and unparseable input silently becomes 0 #221

Closed
opened 2026-08-20 06:23:21 +02:00 by clawbot · 2 comments
Collaborator

Found while implementing #202, with a second defect added from its review.

Two problems in the same helper, parseNonNegativeInt (internal/handlers/source_management.go:1183-1194), which HandleTargetCreate uses for max_retries:

1. No ceiling. Any non-negative value is accepted. parseRetentionDays in the same file shows the intended shape: validate against a stated ceiling and answer 400 naming it. A typo'd extra zero gives MaxRetries of 100000 and the engine will genuinely record that many delivery_results rows, each of which the event log then loads and renders.

2. Unparseable input silently returns 0. This is the silent-fallback pattern the owner rejected on #78 — a set-but-invalid value must fail loudly, never degrade into a default the operator did not ask for. Here a garbage max_retries quietly means "never retry", which is the opposite of what someone typing a number into that field intends, and nothing tells them.

It is operator-chosen rather than sender-chosen, so it is a robustness bug rather than a 1.0 blocker — an operator can only shoot themselves.

Definition of done:

  • max_retries is validated at creation and edit against a stated ceiling, rejected with 400 naming the ceiling
  • unparseable input is rejected with 400, never coerced to 0; audit the other callers of parseNonNegativeInt for the same coercion and fix or justify each
  • the same validation applies on the target edit path added by #127, so the two cannot disagree
  • existing stored values above the ceiling still render and still deliver; this is input validation, not a migration
  • tests cover a form post above the ceiling and an unparseable one, each rejected with the reason named
Found while implementing https://git.eeqj.de/sneak/webhooker/issues/202, with a second defect added from its review. Two problems in the same helper, `parseNonNegativeInt` (`internal/handlers/source_management.go:1183-1194`), which `HandleTargetCreate` uses for `max_retries`: **1. No ceiling.** Any non-negative value is accepted. `parseRetentionDays` in the same file shows the intended shape: validate against a stated ceiling and answer 400 naming it. A typo'd extra zero gives `MaxRetries` of 100000 and the engine will genuinely record that many `delivery_results` rows, each of which the event log then loads and renders. **2. Unparseable input silently returns 0.** This is the silent-fallback pattern the owner rejected on https://git.eeqj.de/sneak/webhooker/pulls/78 — a set-but-invalid value must fail loudly, never degrade into a default the operator did not ask for. Here a garbage `max_retries` quietly means "never retry", which is the opposite of what someone typing a number into that field intends, and nothing tells them. It is operator-chosen rather than sender-chosen, so it is a robustness bug rather than a 1.0 blocker — an operator can only shoot themselves. Definition of done: - `max_retries` is validated at creation and edit against a stated ceiling, rejected with 400 naming the ceiling - unparseable input is rejected with 400, never coerced to 0; audit the other callers of `parseNonNegativeInt` for the same coercion and fix or justify each - the same validation applies on the target edit path added by https://git.eeqj.de/sneak/webhooker/issues/127, so the two cannot disagree - existing stored values above the ceiling still render and still deliver; this is input validation, not a migration - tests cover a form post above the ceiling and an unparseable one, each rejected with the reason named
clawbot changed title from max_retries is unbounded at target creation, so one target can queue arbitrarily many delivery attempts to max_retries is unbounded at target creation, and unparseable input silently becomes 0 2026-08-20 06:38:28 +02:00
clawbot added this to the 1.0.0 milestone 2026-08-24 00:58:20 +02:00
Author
Collaborator

Plan.

Modelled on parseRetentionDays / delivery.ParseTargetTimeout, which already get this right in the same form.

  • New parseMaxRetries(raw string, fallback int) (int, error) in internal/handlers: empty means ABSENT and yields fallback; anything unparseable or negative is an error; anything above the ceiling is a distinct error. No path returns a substituted default for a set-but-invalid value.
  • Ceiling maxTargetRetries = 20. Both templates/source_detail.html and templates/target_edit.html already declare max="20" on the input; the server simply never enforced what the UI advertises. Backoff is 2^(n-1) seconds, so attempt 20 is already ~6 days out, and each attempt writes a delivery_results row the event log then loads.
  • Wired into BOTH paths from one helper: processTargetCreate (fallback 0) and applyTargetEdit (fallback = stored MaxRetries, kept inside the existing PostForm.Has guard so a form that does not render the field still does not touch retries). They cannot disagree because they call the same function.
  • 400 wording matches the timeout control on the same submission: Invalid max retries: ..., naming the ceiling when out of range.
  • Audit of parseNonNegativeInt: its other two callers (finishResubmit, finishReplay) parse the log page number for a redirect, not configuration. Coercion is correct there and already matches parsePage on the GET side. I will delete parseNonNegativeInt and give those callers a pageOrFirst helper shared with parsePage, so no silently-coercing general-purpose int parser is left lying around for a config field to reach for.
  • Validation only. Stored values above the ceiling keep rendering and delivering; no migration, no clamping of existing rows.

Tests on both create and edit: above-ceiling rejected with the ceiling named, abc / 2.7 / -5 / a 20-digit number rejected rather than coerced to 0, a valid value stored, an absent field still taking its default, and the edit case proving a working max_retries=2 survives a rejected submission.

Plan. Modelled on `parseRetentionDays` / `delivery.ParseTargetTimeout`, which already get this right in the same form. - New `parseMaxRetries(raw string, fallback int) (int, error)` in `internal/handlers`: empty means ABSENT and yields `fallback`; anything unparseable or negative is an error; anything above the ceiling is a distinct error. No path returns a substituted default for a set-but-invalid value. - Ceiling `maxTargetRetries = 20`. Both `templates/source_detail.html` and `templates/target_edit.html` already declare `max="20"` on the input; the server simply never enforced what the UI advertises. Backoff is `2^(n-1)` seconds, so attempt 20 is already ~6 days out, and each attempt writes a `delivery_results` row the event log then loads. - Wired into BOTH paths from one helper: `processTargetCreate` (fallback 0) and `applyTargetEdit` (fallback = stored `MaxRetries`, kept inside the existing `PostForm.Has` guard so a form that does not render the field still does not touch retries). They cannot disagree because they call the same function. - 400 wording matches the `timeout` control on the same submission: `Invalid max retries: ...`, naming the ceiling when out of range. - Audit of `parseNonNegativeInt`: its other two callers (`finishResubmit`, `finishReplay`) parse the log page number for a redirect, not configuration. Coercion is correct there and already matches `parsePage` on the GET side. I will delete `parseNonNegativeInt` and give those callers a `pageOrFirst` helper shared with `parsePage`, so no silently-coercing general-purpose int parser is left lying around for a config field to reach for. - Validation only. Stored values above the ceiling keep rendering and delivering; no migration, no clamping of existing rows. Tests on both create and edit: above-ceiling rejected with the ceiling named, `abc` / `2.7` / `-5` / a 20-digit number rejected rather than coerced to 0, a valid value stored, an absent field still taking its default, and the edit case proving a working `max_retries=2` survives a rejected submission.
Author
Collaborator

Done in #259 (base next).

parseNonNegativeInt is removed. max_retries on both the create and the edit form now goes through one parseMaxRetries: empty or omitted takes the caller's fallback (0 at creation, the stored count on edit), and anything else that is not a whole number at most 20 is a 400 that names the reason. Ceiling is 20 because both templates have always declared max="20" on the input and the server never enforced it; backoff is 2^(n-1) seconds, so attempt 20 is already about six days out.

The two other callers (finishResubmit, finishReplay) read the log page number for a post-action redirect, where falling back to page 1 is correct and already matches parsePage on the GET side. They use a pageOrFirst named for that, so no general silently-coercing parser is left in the package. Full disposition table in the PR.

Verified: make check green with GOFLAGS=-count=1 — 570 tests, 0 failures, lint 0 issues in Docker. Ten new tests cover both paths for above-ceiling, abc, 2.7, -5, a 20-digit number, valid values including the ceiling itself, and an absent field still taking its default; one test submits every case to both paths and asserts the verdicts match.

On a running instance, the reported edit case — a target delivering with max_retries=2, re-saved with abc — now answers HTTP 400 Invalid max retries: retries must be a whole number of attempts, or 0 for fire-and-forget, and the stored value is still 2 with the submission's name change not written either. A row set to 100000 directly in the database still renders on both pages and is not clamped.

Done in https://git.eeqj.de/sneak/webhooker/pulls/259 (base `next`). `parseNonNegativeInt` is removed. `max_retries` on both the create and the edit form now goes through one `parseMaxRetries`: empty or omitted takes the caller's fallback (0 at creation, the stored count on edit), and anything else that is not a whole number at most 20 is a 400 that names the reason. Ceiling is 20 because both templates have always declared `max="20"` on the input and the server never enforced it; backoff is `2^(n-1)` seconds, so attempt 20 is already about six days out. The two other callers (`finishResubmit`, `finishReplay`) read the log page number for a post-action redirect, where falling back to page 1 is correct and already matches `parsePage` on the GET side. They use a `pageOrFirst` named for that, so no general silently-coercing parser is left in the package. Full disposition table in the PR. Verified: `make check` green with `GOFLAGS=-count=1` — 570 tests, 0 failures, lint 0 issues in Docker. Ten new tests cover both paths for above-ceiling, `abc`, `2.7`, `-5`, a 20-digit number, valid values including the ceiling itself, and an absent field still taking its default; one test submits every case to both paths and asserts the verdicts match. On a running instance, the reported edit case — a target delivering with `max_retries=2`, re-saved with `abc` — now answers `HTTP 400 Invalid max retries: retries must be a whole number of attempts, or 0 for fire-and-forget`, and the stored value is still 2 with the submission's name change not written either. A row set to 100000 directly in the database still renders on both pages and is not clamped.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#221