SEC2-055: Notifier gives Apprise the title and body with no body_format, so HTML services receive a captured page's words as markup #21

Closed
opened 2026-10-17 10:39:47 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-10-16 in a read-only security review of postulo-apprise at 59e136b, checked by a second, independent pass.

Severity: Medium · Category: Injection · Reference: CWE-79 (Improper Neutralization of Input During Web Page Generation); CWE-116 (Improper Encoding or Escaping of Output); OWASP A03:2021 (Injection)

Location:

  • src/postulo_apprise/notifier.py:205-213 (test: client.notify(title=..., body=...))
  • src/postulo_apprise/notifier.py:236 (send: client.notify(title=notification.title, body=body))
  • src/postulo_apprise/notifier.py:66 (build: apprise.Apprise() with the default asset)
  • Apprise 1.13.1 apprise/plugins/base.py:859-870 (the step that converts the words to the service's format and blends the title in)

Issue

Both calls pass no body_format, and the asset is the default one:

# notifier.py:236
if not client.notify(title=notification.title, body=body):

In Apprise 1.13.1 the input format then falls back to the asset's, which is None
(apprise.py:991-993, asset.py:131). convert_between(from_format, to_format, content)
converts only for the four known pairs and otherwise returns the content unchanged
(conversion.py:80-100); the escaping converter, text_to_html → URLBase.escape_html
(conversion.py:112-116), runs only when the input format is TEXT. So every service whose
notify_format is HTML is handed the words verbatim and sends them as HTML:
plugins/telegram.py:152 (notify_format = NotifyFormat.HTML, sent with parse_mode: HTML,
:1414-1416), plugins/email/base.py:116 (mailto://, text/html body), and brevo,
mailersend, mailgun, office365, postmark, pushplus, resend, sendgrid, sendpulse,
ses, smtp2go, sparkpost.

The words are not the account holder's. For capture_received the core builds the title from
the captured page (notifications/slow.py:92: title=_("Captured: %(title)s") % {"title": payload.get("title", "")}, fed from api/api.py:576). Reminders carry a company name and a
posting title the same way. The README's own first example is a Telegram line, and mailto://
is in the help text.

Impact

A stranger whose posting the person captures (or a job board they capture from) chooses markup
that is rendered inside the person's own trusted channel: a link whose text names Postulo while
its target is elsewhere, hidden text, or, in mail, an image tag whose fetch tells the stranger
when and from where the message was opened. A tag Telegram's parser rejects makes the whole
message fail, and the core then marks the connection failed
(notifications/service.py:106-110), so a hostile title also suppresses that notification.
Who: anyone who can get a page captured by a person using an HTML-format Apprise line; no
account on the instance is needed. Nothing crosses accounts and nothing is read back, which is
why this is Medium rather than High.

Fix

Say what the words are. In both test() and send():

client.notify(title=..., body=..., body_format=apprise.NotifyFormat.TEXT)

or once, in build(): apprise.Apprise(asset=apprise.AppriseAsset(body_format=apprise.NotifyFormat.TEXT)).
Apprise then converts (TEXT, HTML) through escape_html before every HTML service, and the
Telegram plugin escapes (TEXT, MARKDOWN) itself. Two side effects to accept: escape_html
turns spaces into   (Telegram converts them back, telegram.py:335-339; HTML mail keeps
them, which only affects wrapping), and the URL appended to the body arrives as escaped text,
which both channels still autolink.

Done when: a test replacing apprise.Apprise.notify with a recorder asserts
body_format == NotifyFormat.TEXT for both test() and send(), and a test running a real
apprise.Apprise() with a json:// or mailto:// line against a stubbed transport asserts
that a < in the notification title arrives as &lt;.

#12 (open) is about schemes with no remote party and does not touch formats.

Found on 2026-10-16 in a read-only security review of `postulo-apprise` at `59e136b`, checked by a second, independent pass. **Severity:** Medium · **Category:** Injection · **Reference:** CWE-79 (Improper Neutralization of Input During Web Page Generation); CWE-116 (Improper Encoding or Escaping of Output); OWASP A03:2021 (Injection) **Location:** - `src/postulo_apprise/notifier.py:205-213` (`test`: `client.notify(title=..., body=...)`) - `src/postulo_apprise/notifier.py:236` (`send`: `client.notify(title=notification.title, body=body)`) - `src/postulo_apprise/notifier.py:66` (`build`: `apprise.Apprise()` with the default asset) - Apprise 1.13.1 `apprise/plugins/base.py:859-870` (the step that converts the words to the service's format and blends the title in) ## Issue Both calls pass no `body_format`, and the asset is the default one: ```python # notifier.py:236 if not client.notify(title=notification.title, body=body): ``` In Apprise 1.13.1 the input format then falls back to the asset's, which is `None` (`apprise.py:991-993`, `asset.py:131`). `convert_between(from_format, to_format, content)` converts only for the four known pairs and otherwise returns the content unchanged (`conversion.py:80-100`); the escaping converter, `text_to_html` → `URLBase.escape_html` (`conversion.py:112-116`), runs only when the input format is `TEXT`. So every service whose `notify_format` is HTML is handed the words verbatim and sends them as HTML: `plugins/telegram.py:152` (`notify_format = NotifyFormat.HTML`, sent with `parse_mode: HTML`, `:1414-1416`), `plugins/email/base.py:116` (`mailto://`, `text/html` body), and `brevo`, `mailersend`, `mailgun`, `office365`, `postmark`, `pushplus`, `resend`, `sendgrid`, `sendpulse`, `ses`, `smtp2go`, `sparkpost`. The words are not the account holder's. For `capture_received` the core builds the title from the captured page (`notifications/slow.py:92`: `title=_("Captured: %(title)s") % {"title": payload.get("title", "")}`, fed from `api/api.py:576`). Reminders carry a company name and a posting title the same way. The README's own first example is a Telegram line, and `mailto://` is in the help text. ## Impact A stranger whose posting the person captures (or a job board they capture from) chooses markup that is rendered inside the person's own trusted channel: a link whose text names Postulo while its target is elsewhere, hidden text, or, in mail, an image tag whose fetch tells the stranger when and from where the message was opened. A tag Telegram's parser rejects makes the whole message fail, and the core then marks the connection failed (`notifications/service.py:106-110`), so a hostile title also suppresses that notification. Who: anyone who can get a page captured by a person using an HTML-format Apprise line; no account on the instance is needed. Nothing crosses accounts and nothing is read back, which is why this is Medium rather than High. ## Fix Say what the words are. In both `test()` and `send()`: ```python client.notify(title=..., body=..., body_format=apprise.NotifyFormat.TEXT) ``` or once, in `build()`: `apprise.Apprise(asset=apprise.AppriseAsset(body_format=apprise.NotifyFormat.TEXT))`. Apprise then converts `(TEXT, HTML)` through `escape_html` before every HTML service, and the Telegram plugin escapes `(TEXT, MARKDOWN)` itself. Two side effects to accept: `escape_html` turns spaces into `&nbsp;` (Telegram converts them back, `telegram.py:335-339`; HTML mail keeps them, which only affects wrapping), and the URL appended to the body arrives as escaped text, which both channels still autolink. Done when: a test replacing `apprise.Apprise.notify` with a recorder asserts `body_format == NotifyFormat.TEXT` for both `test()` and `send()`, and a test running a real `apprise.Apprise()` with a `json://` or `mailto://` line against a stubbed transport asserts that a `<` in the notification title arrives as `&lt;`. ## Related #12 (open) is about schemes with no remote party and does not touch formats.
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#21
No description provided.