Destination check: Apprise follows HTTP redirects itself, so a checked public webhook can lead the server to a private address #15

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

Found on 2026-09-29 in a read-only review of postulo-apprise at 7637d899d, reading how Apprise 1.13.1 (the locked version) sends once the plugin's check has passed, and inspecting the server objects build() returns.

The plugin checks the host written in the line and nothing after it. Apprise then sends with the requests library and follows 3xx answers by default, so the address finally contacted is chosen by whoever answers at the checked host. The core's own client re-checks every redirect precisely "so a public hostname cannot bounce a plugin onto the router's administration page" (in the core, plugins/http.py:10-12); the Apprise path has no equivalent. The module docstring names one residual gap, the second name lookup; redirects are a second one, simpler to use, and are not mentioned.

  • Where: src/postulo_apprise/notifier.py:66 (build: client = apprise.Apprise(), with the default asset); :205 and :236 (client.notify(...) in test and send); :26-30 (the docstring's one stated gap). In Apprise 1.13.1: url.py:136 (redirects = True), asset.py:198 (http_redirects = True), url.py:252-254 (self.redirects = parse_bool(kwargs.get("redirect", self.asset.http_redirects))), and allow_redirects=self.redirects at 167 call sites in 140 plugin files, among them plugins/custom_json.py:325, plugins/custom_xml.py:416, plugins/custom_form.py:410, plugins/ntfy.py:664 and plugins/gotify.py:241.
  • What happens: checked on the snapshot: the servers build() makes for json://, ntfy:// and gotify:// lines all report redirects=True, and nothing in notifier.py passes an AppriseAsset or reads a redirect argument. With private destinations refused, a line naming a public host passes the form. If that host answers 307 or 308 with a Location on a loopback, link-local or LAN address, requests repeats the same method, headers and body there. For the custom webhook schemes (json, xml, form) the method, extra headers and extra payload fields are all set in the line.
  • Who can trigger it: any account holder on an instance where the plugin is installed and private destinations are refused (the default), who also controls, or can influence the redirects of, a public web server.
  • Why it matters: a request of the account holder's own shaping is delivered to an internal HTTP service from the server's position on the network, and the Test button reports whether it was accepted. That is the request forgery (CWE-918) the destination policy exists to stop, and the one the core closes for every plugin that uses client().
  • Fix:
    • Build with apprise.Apprise(asset=apprise.AppriseAsset(http_redirects=False)).
    • After add(), refuse a server whose redirects is true: a redirect= argument in the line overrides the asset default (checked: with the asset set to False, a line carrying the argument still reports True).
    • pyproject.toml:20 allows apprise>=1.9; raise the floor to a release that has http_redirects, since without it the option is silently ignored and redirects are followed.
    • Say in the README that redirects are not followed, so a server that answers http with a redirect to https needs the …s:// scheme.
    • The longer-term answer the docstring already names, carrying json, xml and form over postulo.plugins.api.client(), closes this and the lookup gap together.

Related to #1 (closed), which named the lookup gap only.

Done when

  • A test in tests/test_apprise.py shows that every server built from a saved line has redirects is False, and that a line carrying redirect=yes is refused by validate() with a readable sentence.
Found on 2026-09-29 in a read-only review of postulo-apprise at 7637d899d, reading how Apprise 1.13.1 (the locked version) sends once the plugin's check has passed, and inspecting the server objects `build()` returns. The plugin checks the host written in the line and nothing after it. Apprise then sends with the `requests` library and follows 3xx answers by default, so the address finally contacted is chosen by whoever answers at the checked host. The core's own client re-checks every redirect precisely "so a public hostname cannot bounce a plugin onto the router's administration page" (in the core, `plugins/http.py:10-12`); the Apprise path has no equivalent. The module docstring names one residual gap, the second name lookup; redirects are a second one, simpler to use, and are not mentioned. - **Where:** `src/postulo_apprise/notifier.py:66` (`build`: `client = apprise.Apprise()`, with the default asset); `:205` and `:236` (`client.notify(...)` in `test` and `send`); `:26-30` (the docstring's one stated gap). In Apprise 1.13.1: `url.py:136` (`redirects = True`), `asset.py:198` (`http_redirects = True`), `url.py:252-254` (`self.redirects = parse_bool(kwargs.get("redirect", self.asset.http_redirects))`), and `allow_redirects=self.redirects` at 167 call sites in 140 plugin files, among them `plugins/custom_json.py:325`, `plugins/custom_xml.py:416`, `plugins/custom_form.py:410`, `plugins/ntfy.py:664` and `plugins/gotify.py:241`. - **What happens:** checked on the snapshot: the servers `build()` makes for `json://`, `ntfy://` and `gotify://` lines all report `redirects=True`, and nothing in `notifier.py` passes an `AppriseAsset` or reads a `redirect` argument. With private destinations refused, a line naming a public host passes the form. If that host answers 307 or 308 with a `Location` on a loopback, link-local or LAN address, `requests` repeats the same method, headers and body there. For the custom webhook schemes (`json`, `xml`, `form`) the method, extra headers and extra payload fields are all set in the line. - **Who can trigger it:** any account holder on an instance where the plugin is installed and private destinations are refused (the default), who also controls, or can influence the redirects of, a public web server. - **Why it matters:** a request of the account holder's own shaping is delivered to an internal HTTP service from the server's position on the network, and the Test button reports whether it was accepted. That is the request forgery (CWE-918) the destination policy exists to stop, and the one the core closes for every plugin that uses `client()`. - **Fix:** - Build with `apprise.Apprise(asset=apprise.AppriseAsset(http_redirects=False))`. - After `add()`, refuse a server whose `redirects` is true: a `redirect=` argument in the line overrides the asset default (checked: with the asset set to `False`, a line carrying the argument still reports `True`). - `pyproject.toml:20` allows `apprise>=1.9`; raise the floor to a release that has `http_redirects`, since without it the option is silently ignored and redirects are followed. - Say in the README that redirects are not followed, so a server that answers `http` with a redirect to `https` needs the `…s://` scheme. - The longer-term answer the docstring already names, carrying `json`, `xml` and `form` over `postulo.plugins.api.client()`, closes this and the lookup gap together. Related to #1 (closed), which named the lookup gap only. ## Done when - A test in `tests/test_apprise.py` shows that every server built from a saved line has `redirects is False`, and that a line carrying `redirect=yes` is refused by `validate()` with a readable sentence.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:48:17 +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#15
No description provided.