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
Labels
No labels
bug
documentation
enhancement
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-apprise#21
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-10-16 in a read-only security review of
postulo-appriseat59e136b, 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/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: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 isTEXT. So every service whosenotify_formatis HTML is handed the words verbatim and sends them as HTML:plugins/telegram.py:152(notify_format = NotifyFormat.HTML, sent withparse_mode: HTML,:1414-1416),plugins/email/base.py:116(mailto://,text/htmlbody), andbrevo,mailersend,mailgun,office365,postmark,pushplus,resend,sendgrid,sendpulse,ses,smtp2go,sparkpost.The words are not the account holder's. For
capture_receivedthe core builds the title fromthe captured page (
notifications/slow.py:92:title=_("Captured: %(title)s") % {"title": payload.get("title", "")}, fed fromapi/api.py:576). Reminders carry a company name and aposting 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()andsend():or once, in
build():apprise.Apprise(asset=apprise.AppriseAsset(body_format=apprise.NotifyFormat.TEXT)).Apprise then converts
(TEXT, HTML)throughescape_htmlbefore every HTML service, and theTelegram plugin escapes
(TEXT, MARKDOWN)itself. Two side effects to accept:escape_htmlturns spaces into
(Telegram converts them back,telegram.py:335-339; HTML mail keepsthem, 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.notifywith a recorder assertsbody_format == NotifyFormat.TEXTfor bothtest()andsend(), and a test running a realapprise.Apprise()with ajson://ormailto://line against a stubbed transport assertsthat a
<in the notification title arrives as<.Related
#12 (open) is about schemes with no remote party and does not touch formats.
Fixed by pull request #23.