Apprise URLs: a line may set Apprise's socket timeouts (rto=, cto=), so one connection can hold the shared scheduler for hours #17

Closed
opened 2026-10-01 10:48:19 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-09-29 in a read-only review of postulo-apprise at 7637d899d, inspecting the server objects build() returns for lines that carry Apprise's generic arguments, and following send() back to the scheduler's pass in the core.

Every Apprise URL accepts rto= and cto=, the socket read and connect timeouts (4 seconds each by default), and the plugin hands lines to Apprise unchanged. send() is called synchronously from the scheduler's single pass, with no deadline around a notifier. A line that raises the read timeout and points at an endpoint that accepts the connection and never answers keeps the pass waiting for as long as the line says. The core gives its own client a fixed 10-second timeout for this reason; the Apprise path has no ceiling.

  • Where: src/postulo_apprise/notifier.py:59-71 (build, which adds each line as typed); :205 and :236 (client.notify(...) in test and send, with nothing around them). In Apprise 1.13.1, url.py:293-304 reads both values from the line and url.py:823-826 passes them to requests as the timeout. In the core: notifications/service.py:72 (plugin.send(...), no deadline), notifications/management/commands/send_due_reminders.py:55-98 (one notify per due reminder, every account in turn) and :187-196 (one_pass, where document copies, webhooks and syncs wait behind the reminders).
  • What happens: checked on the snapshot: a json:// line with rto=86400&cto=86400 passes validate() ({}), and the server the plugin builds reports socket_connect_timeout=86400.0, socket_read_timeout=86400.0. Nothing in notifier.py reads or caps either value; the form caps only the field's length (20,000 characters, in the core's plugins/forms.py:127), not the number of lines. At that account's next due reminder the scheduler waits for the endpoint until the timeout runs out before it moves on.
  • Who can trigger it: any account holder on a multi-user instance where the plugin is installed, with a public endpoint that is slow or silent, by design or by fault.
  • Why it matters: one account's connection delays every other account's reminders, quiet-application and closing notices, document copies, webhook deliveries and syncs, on every pass in which that account has something to announce. The same connection's Test button holds a web worker until gunicorn's 120-second limit (docker/Dockerfile:354 in the core) ends it.
  • Fix:
    • After build(), clamp each server's socket_connect_timeout and socket_read_timeout to a small ceiling (the core's 10 seconds is the natural one), or refuse on the form a line that sets rto or cto above it.
    • A read timeout bounds each read, not the whole request, so an endpoint that answers a byte at a time still outlasts it: also bound the wait on client.notify() with one overall deadline per send, and report the connection as failed when it passes.
    • Cap the number of lines a connection may hold.

Done when

  • A test in tests/test_apprise.py shows that a line with a large rto and cto yields a server whose two timeouts are at the ceiling (or is refused by validate()), and that a line without them is unchanged.
  • A test in tests/test_apprise.py, with apprise.Apprise.notify replaced by one that blocks, shows send() raising once the overall deadline has passed.
Found on 2026-09-29 in a read-only review of postulo-apprise at 7637d899d, inspecting the server objects `build()` returns for lines that carry Apprise's generic arguments, and following `send()` back to the scheduler's pass in the core. Every Apprise URL accepts `rto=` and `cto=`, the socket read and connect timeouts (4 seconds each by default), and the plugin hands lines to Apprise unchanged. `send()` is called synchronously from the scheduler's single pass, with no deadline around a notifier. A line that raises the read timeout and points at an endpoint that accepts the connection and never answers keeps the pass waiting for as long as the line says. The core gives its own client a fixed 10-second timeout for this reason; the Apprise path has no ceiling. - **Where:** `src/postulo_apprise/notifier.py:59-71` (`build`, which adds each line as typed); `:205` and `:236` (`client.notify(...)` in `test` and `send`, with nothing around them). In Apprise 1.13.1, `url.py:293-304` reads both values from the line and `url.py:823-826` passes them to `requests` as the timeout. In the core: `notifications/service.py:72` (`plugin.send(...)`, no deadline), `notifications/management/commands/send_due_reminders.py:55-98` (one `notify` per due reminder, every account in turn) and `:187-196` (`one_pass`, where document copies, webhooks and syncs wait behind the reminders). - **What happens:** checked on the snapshot: a `json://` line with `rto=86400&cto=86400` passes `validate()` (`{}`), and the server the plugin builds reports `socket_connect_timeout=86400.0`, `socket_read_timeout=86400.0`. Nothing in `notifier.py` reads or caps either value; the form caps only the field's length (20,000 characters, in the core's `plugins/forms.py:127`), not the number of lines. At that account's next due reminder the scheduler waits for the endpoint until the timeout runs out before it moves on. - **Who can trigger it:** any account holder on a multi-user instance where the plugin is installed, with a public endpoint that is slow or silent, by design or by fault. - **Why it matters:** one account's connection delays every other account's reminders, quiet-application and closing notices, document copies, webhook deliveries and syncs, on every pass in which that account has something to announce. The same connection's Test button holds a web worker until gunicorn's 120-second limit (`docker/Dockerfile:354` in the core) ends it. - **Fix:** - After `build()`, clamp each server's `socket_connect_timeout` and `socket_read_timeout` to a small ceiling (the core's 10 seconds is the natural one), or refuse on the form a line that sets `rto` or `cto` above it. - A read timeout bounds each read, not the whole request, so an endpoint that answers a byte at a time still outlasts it: also bound the wait on `client.notify()` with one overall deadline per send, and report the connection as failed when it passes. - Cap the number of lines a connection may hold. ## Done when - A test in `tests/test_apprise.py` shows that a line with a large `rto` and `cto` yields a server whose two timeouts are at the ceiling (or is refused by `validate()`), and that a line without them is unchanged. - A test in `tests/test_apprise.py`, with `apprise.Apprise.notify` replaced by one that blocks, shows `send()` raising once the overall deadline has passed.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:48:19 +00:00
Author
Owner

Fixed by pull request #23.

Fixed by pull request #23.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-apprise#17
No description provided.