Destination check: a mail line's smtp= argument and FCM's keyfile= name the address dialled, and neither is checked #10

Closed
opened 2026-10-01 10:48:13 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo-apprise at 7637d899d, comparing the one address destination_of() submits to the policy with the address each Apprise service opens, with Apprise 1.13.1 (the locked version) and every socket stubbed out.

The plugin holds a line to the instance's destination policy by reading server.host. For some Apprise services the host slot is not what is connected to: an argument elsewhere in the line names it. The policy is then asked about a name that is never contacted, and the address that is contacted is never looked at. This is server-side request forgery (CWE-918) past the operator's POSTULO_CONNECTIONS_ALLOW_PRIVATE setting, and unlike the lookup gap the module docstring concedes it involves no timing: it is deterministic.

  • Where: src/postulo_apprise/notifier.py:102-120 (destination_of: host = str(getattr(server, "host", "") or "") at :110 is the only attribute read, with port); :74-99 (names_a_server, which sorts by class, so a class with no host token has no destination at all); :177-187 (_problems, the only caller of check_destination). In Apprise 1.13.1: plugins/email/base.py:190 maps the smtp argument to smtp_host, :472-473 falls back to host only when smtp_host is empty, and :680-691 opens smtplib.SMTP(self.smtp_host, self.port, …); plugins/fcm/__init__.py:286-288 turns keyfile into an AppriseAttachment, which attachment/http.py:148 fetches with requests.get (or, for a path, plugins/fcm/oauth.py:117 opens); plugins/xmpp/base.py:143-146 maps xmpp to xmpp_host the same way when slixmpp is installed.
  • What happens: checked on the snapshot with private destinations off and a stub resolver that knows only example.org.
    • A mailto:// line whose domain is example.org, with a port and an smtp= argument naming a loopback address: validate() returns {}, having looked up example.org and nothing else; the server object reports host='example.org', smtp_host='127.0.0.1'; pressing Test makes Apprise open smtplib.SMTP('127.0.0.1', 2525).
    • An fcm:// line with a keyfile= argument holding an http:// address on loopback: FCM declares no host token, so destination_of returns None and nothing is checked at all; pressing Test makes Apprise issue GET to that address, with redirects followed.
    • Nothing in notifier.py reads smtp_host, a query argument, or any attribute other than host and port, and tests/test_apprise.py:96-119 covers only the host slot.
  • Who can trigger it: any account holder on an instance where the plugin is installed and private destinations are refused (the default). No other precondition: the form accepts the line, and the Test button and every later notification dial out.
  • Why it matters: the server opens SMTP sessions to loopback, LAN or Compose-network addresses and ports of the account holder's choosing. An internal relay that trusts local clients can be made to send mail with a sender and recipients the account holder chose, and the Test button's delivered/refused answer says which internal hosts and ports are listening. With keyfile=, the server issues a request to any internal address, or opens a local path, whenever a notification is sent. The threat model's promise that connected plugins reach the network only under the operator's destination policy does not hold for these lines.
  • Fix: ask each service for what it dials rather than for its host token.
    • For the mail classes, check smtp_host and its port in place of host.
    • Refuse, or check as destinations in their own right, the arguments that carry an address (smtp, xmpp, keyfile), and refuse a keyfile that is a local path.
    • Apprise adds such arguments over time, so the durable form is a short table of schemes whose dialled address has been reviewed, each with an accessor, and a refusal with a readable sentence for a line that carries an address-bearing argument the table does not know. The same table answers the separate problem of services whose host is not an address at all (cloud ntfy, the API mail providers).
    • Correct the module docstring, which names the second name lookup as the one thing the check cannot do.

Related to #1 (closed), which set out to check every destination and fixed hosts without a dot; addresses carried outside the host slot were not part of it.

Done when

  • A test in tests/test_apprise.py, with POSTULO_CONNECTIONS_ALLOW_PRIVATE off, shows AppriseNotifier().validate() refusing a mail line whose domain is public and whose smtp= argument is a private address, and accepting the same line without the argument.
  • A test in tests/test_apprise.py shows an FCM line with a keyfile= address or path refused (or the address checked), and test() and send() not reaching Apprise for either line.
Found on 2026-09-29 in a read-only review of postulo-apprise at 7637d899d, comparing the one address `destination_of()` submits to the policy with the address each Apprise service opens, with Apprise 1.13.1 (the locked version) and every socket stubbed out. The plugin holds a line to the instance's destination policy by reading `server.host`. For some Apprise services the host slot is not what is connected to: an argument elsewhere in the line names it. The policy is then asked about a name that is never contacted, and the address that is contacted is never looked at. This is server-side request forgery (CWE-918) past the operator's `POSTULO_CONNECTIONS_ALLOW_PRIVATE` setting, and unlike the lookup gap the module docstring concedes it involves no timing: it is deterministic. - **Where:** `src/postulo_apprise/notifier.py:102-120` (`destination_of`: `host = str(getattr(server, "host", "") or "")` at `:110` is the only attribute read, with `port`); `:74-99` (`names_a_server`, which sorts by class, so a class with no `host` token has no destination at all); `:177-187` (`_problems`, the only caller of `check_destination`). In Apprise 1.13.1: `plugins/email/base.py:190` maps the `smtp` argument to `smtp_host`, `:472-473` falls back to `host` only when `smtp_host` is empty, and `:680-691` opens `smtplib.SMTP(self.smtp_host, self.port, …)`; `plugins/fcm/__init__.py:286-288` turns `keyfile` into an `AppriseAttachment`, which `attachment/http.py:148` fetches with `requests.get` (or, for a path, `plugins/fcm/oauth.py:117` opens); `plugins/xmpp/base.py:143-146` maps `xmpp` to `xmpp_host` the same way when `slixmpp` is installed. - **What happens:** checked on the snapshot with private destinations off and a stub resolver that knows only `example.org`. - A `mailto://` line whose domain is `example.org`, with a port and an `smtp=` argument naming a loopback address: `validate()` returns `{}`, having looked up `example.org` and nothing else; the server object reports `host='example.org'`, `smtp_host='127.0.0.1'`; pressing Test makes Apprise open `smtplib.SMTP('127.0.0.1', 2525)`. - An `fcm://` line with a `keyfile=` argument holding an `http://` address on loopback: FCM declares no `host` token, so `destination_of` returns `None` and nothing is checked at all; pressing Test makes Apprise issue `GET` to that address, with redirects followed. - Nothing in `notifier.py` reads `smtp_host`, a query argument, or any attribute other than `host` and `port`, and `tests/test_apprise.py:96-119` covers only the host slot. - **Who can trigger it:** any account holder on an instance where the plugin is installed and private destinations are refused (the default). No other precondition: the form accepts the line, and the Test button and every later notification dial out. - **Why it matters:** the server opens SMTP sessions to loopback, LAN or Compose-network addresses and ports of the account holder's choosing. An internal relay that trusts local clients can be made to send mail with a sender and recipients the account holder chose, and the Test button's delivered/refused answer says which internal hosts and ports are listening. With `keyfile=`, the server issues a request to any internal address, or opens a local path, whenever a notification is sent. The threat model's promise that connected plugins reach the network only under the operator's destination policy does not hold for these lines. - **Fix:** ask each service for what it dials rather than for its host token. - For the mail classes, check `smtp_host` and its port in place of `host`. - Refuse, or check as destinations in their own right, the arguments that carry an address (`smtp`, `xmpp`, `keyfile`), and refuse a `keyfile` that is a local path. - Apprise adds such arguments over time, so the durable form is a short table of schemes whose dialled address has been reviewed, each with an accessor, and a refusal with a readable sentence for a line that carries an address-bearing argument the table does not know. The same table answers the separate problem of services whose `host` is not an address at all (cloud ntfy, the API mail providers). - Correct the module docstring, which names the second name lookup as the one thing the check cannot do. Related to #1 (closed), which set out to check every destination and fixed hosts without a dot; addresses carried outside the host slot were not part of it. ## Done when - A test in `tests/test_apprise.py`, with `POSTULO_CONNECTIONS_ALLOW_PRIVATE` off, shows `AppriseNotifier().validate()` refusing a mail line whose domain is public and whose `smtp=` argument is a private address, and accepting the same line without the argument. - A test in `tests/test_apprise.py` shows an FCM line with a `keyfile=` address or path refused (or the address checked), and `test()` and `send()` not reaching Apprise for either line.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:48:13 +00:00
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#10
No description provided.